refactor(core): migrate to Bun and converge acquisition policies and Agent boundaries
This commit is contained in:
1 parent
3f1c6d02f6
commit
27c0f27434
268 files changed
+7468
-7852
No files matched your search
@@ -0,0 +1 @@
|
||||
1.4.3
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
# 构建上下文只需要源码与锁文件;依赖在镜像内由 deno install --frozen 按 deno.lock 重装
|
||||
# 构建上下文只需要源码与锁文件;依赖在镜像内由 bun install --frozen-lockfile 按 bun.lock 重装
|
||||
.git
|
||||
**/node_modules
|
||||
**/dist
|
||||
@@ -14,6 +14,7 @@ docs
|
||||
!.env.example
|
||||
*.md
|
||||
!apps/**/README.md
|
||||
!packages/agent/skills/**/*.md
|
||||
data
|
||||
.local
|
||||
.browser-verify-out
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
# UHD Media Tool
|
||||
|
||||
影视自动化工具:站点 / RSS 获取 → 识别 → 下载(qB)→ 整理入库 → 刮削,带 Agent。后端 Deno + Hono + Kysely(PostgreSQL),前端 React Router 8 + Vite,同一个 workspace。业务逻辑以 MoviePilot v3 为参考(`docs/porting/`)。
|
||||
影视自动化工具:站点 / RSS 获取 → 识别 → 下载(qB)→ 整理入库 → 刮削,带 Agent。后端 Bun 1.4.3 + Hono + Kysely(PostgreSQL),前端 React Router 8 + Vite,同一个 workspace。业务逻辑以 MoviePilot v3 为参考(`docs/porting/`)。
|
||||
|
||||
面向团队内部使用,目前只有本机测试环境:**避免过度设计和过度防御**,只处理已确认会影响正常使用的问题。回复和文档用中文。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```sh
|
||||
deno task dev:up # 开发库(127.0.0.1:55433)+ 迁移到最新;拉代码后先跑
|
||||
deno task dev # API 4390 + web 5180(4380 是本机部署版,别占)
|
||||
deno task test:all # 全量测试,逐包跑完汇总;连不上测试库直接失败
|
||||
deno task typecheck # tsc + deno check
|
||||
deno task lint # biome;deno task fmt 会写文件,只对自己改的路径用
|
||||
deno task ci # 提交前完整检查(含 web 构建与编译产物自检)
|
||||
deno task release # 测试环境发布,见下
|
||||
bun run dev:up # 开发库(127.0.0.1:55433)+ 迁移到最新;拉代码后先跑
|
||||
bun run dev # API 4390 + web 5180(4380 是本机部署版,别占)
|
||||
bun run test:all # 全量测试,逐包跑完汇总;连不上测试库直接失败
|
||||
bun run typecheck # tsc 7
|
||||
bun run lint # biome;bun run fmt 会写文件,只对自己改的路径用
|
||||
bun run ci # 提交前完整检查(含 web 构建与编译产物自检)
|
||||
bun run release # 测试环境发布,见下
|
||||
```
|
||||
|
||||
REST 端点定义按领域分在 `packages/contracts/src/api/*.ts`,`api.ts` 按顺序合并成 `Api`(顺序即路由注册顺序)。整理服务按阶段分在 `organize-plan / -exec / -finalize / -records / -types.ts`,外部仍从 `organize.ts` 引用。
|
||||
|
||||
只改一个包时可在包目录跑 `deno run -A npm:vitest run <文件>`;web 包用 `../../node_modules/.bin/vitest run`(Node)。改迁移后跑 `deno task gen:types` 重新生成 `packages/db/src/types.ts`,contracts 枚举顺序与数据库保持一致(`packages/contracts/test/db-sync.test.ts` 会查)。
|
||||
只改一个包时可在包目录跑 `bun x --bun vitest run <文件>`;web 包用 `../../node_modules/.bin/vitest run`(Bun)。改迁移后跑 `bun run gen:types` 重新生成 `packages/db/src/types.ts`,contracts 枚举顺序与数据库保持一致(`packages/contracts/test/db-sync.test.ts` 会查)。
|
||||
|
||||
## 约定
|
||||
|
||||
- **先提交再部署**。发布只走 `deno task release`:工作区不干净直接拒绝;版本取根 `deno.json`(所有成员包同一版本);`CHANGELOG.md` 必须先有 `## [x.y.z]` 条目;脚本打 tag `vx.y.z`、构建镜像、只替换 app 容器(启动时自动迁移)、核对健康接口版本。测试环境不需要备份、在途任务清单、源码指纹等重流程。
|
||||
- 工程设计遵循 `docs/runbook/dev.md`「工程设计与适度抽象」:业务服务提供稳定入口,路由与能力共用;及时提取已有稳定的共用机制,执行策略和业务副作用保持显式。状态由对应业务模块维护,配置在模块入口解析;不提前创建通用框架。
|
||||
- **先提交再部署**。发布只走 `bun run release`:工作区不干净直接拒绝;版本取根 `package.json`(所有成员包同一版本);`CHANGELOG.md` 必须先有 `## [x.y.z]` 条目;脚本打 tag `vx.y.z`、构建镜像、只替换 app 容器(启动时自动迁移)、核对健康接口版本。测试环境不需要备份、在途任务清单、源码指纹等重流程。
|
||||
- 功能在分支上做,完成后合回 main;提交按功能拆,别把不相关的改动带进同一个提交。
|
||||
- 说"检查通过"只认 `deno task test:all` / `deno task ci` 的实际输出;跳过的用例不算通过。
|
||||
- 解析外部下载 / 上传的文件或字节(种子、字幕、压缩包、图片等)一律走沙箱任务 `runSandbox`(`apps/server/src/sandbox/`,见 ADR-005),不在主进程里直接解析。
|
||||
- 说"检查通过"只认 `bun run test:all` / `bun run ci` 的实际输出;跳过的用例不算通过。
|
||||
- 解析外部下载 / 上传的文件或字节(种子、字幕、压缩包、图片等)一律走沙箱任务 `runSandbox`(`apps/server/src/sandbox/`,见 ADR-005 与 ADR-007),不在主进程里直接解析。
|
||||
- 新增 Agent 能力要登记到某个 Skill(`packages/agent/skills/*/SKILL.md`)和能力地图(`packages/agent/src/prompt/layers/capabilities.ts`),否则 agent 测试会红。
|
||||
- 界面文案守 `docs/design/glossary.md` 的术语与标点(`deno task check:copy`);状态色只表示状态(`StatusBadge` 带圆点,别拿来显示数量)。
|
||||
- 主工作区不留常驻 dev 进程(`--watch` 会改写 `deno.lock`);构建前确认 `deno.lock` 没被改。
|
||||
- 界面文案守 `docs/design/glossary.md` 的术语与标点(`bun run check:copy`);状态色只表示状态(`StatusBadge` 带圆点,别拿来显示数量)。
|
||||
- 主工作区不留常驻 dev 进程;依赖安装使用 `bun install --frozen-lockfile` 保持锁文件一致。
|
||||
|
||||
## 测试
|
||||
|
||||
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
# 单容器镜像:一个 deno compile 出的 umt-server 二进制,同时承担 API(含 web 静态 SPA)与 worker。
|
||||
# 单容器镜像:一个 bun build --compile 出的 umt-server 二进制,同时承担 API(含 web 静态 SPA)与 worker。
|
||||
# PostgreSQL 不在本镜像内,见 docker-compose.yml。
|
||||
# 构建:docker build -t uhd-media-tool . 运行时版本锁定 Deno 2.9.7(ADR-004)
|
||||
# 构建:docker build -t uhd-media-tool . 运行时版本锁定 Bun 1.4.3(ADR-007)
|
||||
# ffprobe 取自静态构建镜像(单个 static-pie 二进制,约 135 MB;Debian 的 ffmpeg 包要拉 200+ 个依赖、约 450 MB)
|
||||
ARG FFPROBE_IMAGE=mwader/static-ffmpeg:9.0.2@sha256:7d9bdaaf887f7e6ce6151f67325c344074b5ff1fb75316011c3376503e449a7b
|
||||
FROM ${FFPROBE_IMAGE} AS ffprobe
|
||||
@@ -8,29 +8,27 @@ FROM ${FFPROBE_IMAGE} AS ffprobe
|
||||
# 与 PG 18 服务端同主版本的备份工具,避免 Debian 默认 17 客户端拒绝导出 PG 18。
|
||||
FROM postgres:18.6-trixie@sha256:5a5a84b19854a9ffaa54082c166ff4ec27473a361e496e5ea167f298f2da9722 AS postgres-tools
|
||||
|
||||
FROM denoland/deno:2.9.7@sha256:fa335acdf6b72106eda2cb6a8cb5f4187e7630e357467489db4b2e7352d5e432 AS build
|
||||
FROM oven/bun:1.4.3 AS build
|
||||
ARG APT_MIRROR=""
|
||||
RUN if [ -n "$APT_MIRROR" ]; then sed -i "s|deb.debian.org|$APT_MIRROR|g" /etc/apt/sources.list.d/debian.sources; fi \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends build-essential libnode-dev && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN deno install --frozen
|
||||
RUN bun install --frozen-lockfile
|
||||
ARG APP_VERSION=""
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
RUN deno task check:tokens && cd apps/web && deno task build
|
||||
# --node-modules-dir=none + --exclude-unused-npm:只嵌入 server 依赖图可达的 npm 包(约 110 MB,而不是整个 node_modules)
|
||||
# 权限在编译时固定:读写不限路径(库根/下载目录由用户挂载,位置不固定;写保护靠 /app root 只读 + 非 root 运行);
|
||||
# --allow-run 只放 ffprobe(入库探测)、sha256sum(强制全量哈希走子进程,coreutils 自带)与 /app/umt-server 自身
|
||||
# (fsproxy 把单个文件操作放进 `umt-server --role fsworker` 一次性子进程,挂死可 SIGKILL 回收;二进制必须装在 /app/umt-server)
|
||||
# 编译后立即自检:空环境跑 --role preflight,模块图缺件(ERR_MODULE_NOT_FOUND)在构建期就失败,不留到容器启动
|
||||
RUN cd apps/server && deno task compile && cd /src && sh scripts/check-compiled-binary.sh
|
||||
RUN bun run check:tokens && cd apps/web && bun run build
|
||||
RUN cd apps/server && bun run compile && cd /src && sh scripts/check-compiled-binary.sh
|
||||
|
||||
FROM debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a
|
||||
# gosu 用于降权;PG 18 客户端来自固定的官方同发行版镜像,这里只安装动态库。
|
||||
# Deno 自带根证书;健康探测用二进制自身的 fetch。
|
||||
# 安装系统根证书;健康探测用二进制自身的 fetch。
|
||||
# 国内网络可 --build-arg APT_MIRROR=mirrors.ustc.edu.cn 换源
|
||||
ARG APT_MIRROR=""
|
||||
RUN if [ -n "$APT_MIRROR" ]; then sed -i "s|deb.debian.org|$APT_MIRROR|g" /etc/apt/sources.list.d/debian.sources; fi \
|
||||
&& apt-get update \
|
||||
&& apt-get upgrade -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends gosu libssl3t64 libgssapi-krb5-2 libldap2 libzstd1 liblz4-1 libreadline8t64 \
|
||||
&& apt-get install -y --no-install-recommends gosu ca-certificates libssl3t64 libgssapi-krb5-2 libldap2 libzstd1 liblz4-1 libreadline8t64 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd --system --gid 10001 umt \
|
||||
&& useradd --system --uid 10001 --gid 10001 --home-dir /data --no-create-home --shell /usr/sbin/nologin umt \
|
||||
@@ -42,6 +40,8 @@ RUN ldconfig && pg_dump --version && pg_restore --version && psql --version
|
||||
# /app 全部 root 属主、只读:运行用户(缺省 10001,可用 PUID/PGID 覆盖)改不了二进制与静态产物
|
||||
COPY --from=ffprobe --chown=root:root --chmod=0555 /ffprobe /usr/local/bin/ffprobe
|
||||
COPY --from=build --chown=root:root --chmod=0555 /src/dist/umt-server /app/umt-server
|
||||
COPY --from=build --chown=root:root /src/dist/sandbox-policy.node /app/sandbox-policy.node
|
||||
COPY --from=build --chown=root:root /src/dist/skills /app/skills
|
||||
COPY --from=build --chown=root:root /src/apps/web/build/client /app/public
|
||||
COPY --chown=root:root --chmod=0555 docker/entrypoint.sh /app/entrypoint.sh
|
||||
COPY --chown=root:root --chmod=0555 scripts/backup.sh scripts/restore.sh /app/scripts/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
内部使用、Agent 自动化优先的媒体整理入库平台。需求基线见 [media_automation_requirements_architecture_v0.7.md](media_automation_requirements_architecture_v0.7.md)。
|
||||
|
||||
当前版本 v0.3.37,本机测试环境已部署;各版变化见 [CHANGELOG.md](CHANGELOG.md)。业务逻辑以 MoviePilot v3 为参考,逐项对照见 [MoviePilot v3 对照](docs/porting/moviepilot-v3-comparison.md)。正在推进的计划与待办见 [docs/plan](docs/plan/README.md),开发与发布约定见 [CLAUDE.md](CLAUDE.md)。
|
||||
源码版本以根目录 `package.json` 为准,各版变化见 [CHANGELOG.md](CHANGELOG.md)。业务逻辑以 MoviePilot v3 为参考,逐项对照见 [MoviePilot v3 对照](docs/porting/moviepilot-v3-comparison.md)。正在推进的计划与待办见 [docs/plan](docs/plan/README.md),开发与发布约定见 [CLAUDE.md](CLAUDE.md)。
|
||||
|
||||
## 功能
|
||||
|
||||
@@ -25,24 +25,24 @@
|
||||
|
||||
## 快速启动
|
||||
|
||||
需要 Deno 2.9.7(锁定版本:`curl -fsSL https://deno.land/install.sh | sh -s v2.9.7`)和 Docker。不需要 Node.js / pnpm。
|
||||
需要 Bun 1.4.3(`curl -fsSL https://bun.sh/install | bash -s "bun-v1.4.3"`)和 Docker。解析沙箱使用 Linux seccomp;本地构建还需要 C 编译器及 Node-API 头文件(Debian/Ubuntu: `apt-get install build-essential libnode-dev`)。
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
deno install # 按 deno.lock 安装依赖到 node_modules
|
||||
deno task dev:up # 开发库 postgres:18.6(端口 55433)+ 迁移到最新;拉代码后也跑一次
|
||||
deno task dev # API http://localhost:4390 控制台 http://localhost:5180
|
||||
bun install --frozen-lockfile # 按 bun.lock 安装依赖到 node_modules
|
||||
bun run dev:up # 开发库 postgres:18.6(端口 55433)+ 迁移到最新;拉代码后也跑一次
|
||||
bun run dev # API http://localhost:4390 控制台 http://localhost:5180
|
||||
```
|
||||
|
||||
提交前:`deno task ci`(typecheck:tsc 7.0.2 + deno check → biome → test:all(逐包跑完汇总,要求测试库可连)→ check:tokens → check:copy → web 构建 + server 编译 → check:binary)。
|
||||
提交前:`bun run ci`(typecheck:tsc 7.0.2 → biome → test:all(逐包跑完汇总,要求测试库可连)→ check:tokens → check:copy → web 构建 + server 编译 → check:binary)。
|
||||
|
||||
### 前端冒烟(`deno task test:web:smoke`)
|
||||
### 前端冒烟(`bun run test:web:smoke`)
|
||||
|
||||
Playwright(`playwright-core`,用本机 Chromium)登录后逐个打开控制台全部路由(详情页从列表接口取第一条真实 ID),检查页面渲染出来、没有控制台报错 / 未捕获异常 / 4xx·5xx 的 API 请求,并逐页截图。不在 `deno task ci` 里,需要先起好后端与 web、并已初始化管理员:
|
||||
Playwright(`playwright-core`,用本机 Chromium)登录后逐个打开控制台全部路由(详情页从列表接口取第一条真实 ID),检查页面渲染出来、没有控制台报错 / 未捕获异常 / 4xx·5xx 的 API 请求,并逐页截图。不在 `bun run ci` 里,需要先起好后端与 web、并已初始化管理员:
|
||||
|
||||
```sh
|
||||
deno task dev # 或单容器部署后直接用 4380
|
||||
SMOKE_BASE_URL=http://127.0.0.1:5180 SMOKE_USER=<管理员> SMOKE_PASSWORD=<密码> deno task test:web:smoke
|
||||
bun run dev # 或单容器部署后直接用 4380
|
||||
SMOKE_BASE_URL=http://127.0.0.1:5180 SMOKE_USER=<管理员> SMOKE_PASSWORD=<密码> bun run test:web:smoke
|
||||
```
|
||||
|
||||
可选:`SMOKE_OUT`(截图目录,缺省 `apps/web/test/screenshots/smoke`,已 gitignore)、`SMOKE_CHROMIUM`(浏览器路径,缺省 `/usr/bin/chromium`)、`SMOKE_SCREENSHOTS=0`。全部通过退出 0,有失败 1,前置条件不满足 2。
|
||||
@@ -77,15 +77,18 @@ docker compose --env-file .env.deploy exec app umt backup # 备份
|
||||
| `packages/domain` | 纯业务逻辑,无 IO |
|
||||
| `packages/mapping` | domain ↔ contracts 取值与结构映射(只依赖 domain + contracts) |
|
||||
| `packages/adapters` | qB / RSS / 站点 / TMDB / Emby / 文件系统适配 |
|
||||
| `packages/runtime` | Bun 运行时适配与共用子进程协议 |
|
||||
| `packages/tasks` | 持久任务与 outbox |
|
||||
| `packages/agent` | Pi 接入与能力注册桥接 |
|
||||
| `packages/testkit` | mock 服务与测试夹具 |
|
||||
| `docs/adr` | 架构决策记录 |
|
||||
| `docs/design` | 设计系统规范 |
|
||||
| `docs/runbook` | 运行手册 |
|
||||
| `config/` | tsc 7 用的 tsconfig 基座(根目录不放 tsconfig.json,否则 `deno check` 丢 Deno 全局) |
|
||||
| `config/` | tsc 7 用的 tsconfig 基座(Node + Bun 类型) |
|
||||
| `docker/` | 容器入口脚本、compose 环境变量示例 |
|
||||
| `scripts/` | 备份 / 恢复脚本(镜像内 `/app/scripts/`) |
|
||||
| `spikes/movieclaw-src/` | MovieClaw 只读源码克隆(不进 git;`scripts/mp-sync/movieclaw-import.mjs` 导入站点定义用) |
|
||||
|
||||
技术选型:Deno 2.9.7(锁定)+ TypeScript 7.0.2 strict,deno.json workspace(`nodeModulesDir: auto`,npm 依赖版本统一登记在根 `deno.json` 的 `imports`,`deno.lock` 锁定)。前端 Vite 8 + React Router 8 由 Deno 执行。决策见 [ADR-004](docs/adr/ADR-004-deno-runtime.md)(取代 ADR-001)。
|
||||
技术选型:Bun 1.4.3(锁定)+ TypeScript 7.0.2 strict,package.json workspace,npm 依赖由 bun.lock 锁定。前端 Vite 8 + React Router 8 由 Bun 执行。决策见 [ADR-007](docs/adr/ADR-007-bun-runtime.md)(取代 ADR-004)。
|
||||
|
||||
解析沙箱预加载解析器和 WASM 后,通过 Node-API 原生模块安装 Linux seccomp 系统调用白名单,覆盖全部 Bun 线程。任务执行阶段禁止路径文件访问、网络、执行程序和创建子进程;隔离无法建立时直接失败。超时和 RSS 超限仍由父进程 SIGKILL。生产镜像同时安装编译二进制、沙箱策略模块与内置 skills。
|
||||
+39
-77
@@ -1,95 +1,57 @@
|
||||
# @umt/server
|
||||
|
||||
服务进程(Deno):Hono HTTP API(前缀 `/api/v1`,OpenAPI 见 `/api/openapi.json`、`/api/docs`)、SSE 事件流与 worker 入口。`--role api|worker|worker:agent|all` 决定本进程承担哪部分,默认 `all` 单进程运行(`worker` 只认领 io 组任务,`worker:agent` 只认领 Agent 组;另有内部角色 `--role fsworker`,由 FsProxy 以子进程方式拉起执行单个文件操作,不要手动启动)。业务规则不写在这里,只做编排、鉴权、审计与协议适配。
|
||||
服务进程(Bun 1.4.3):Hono HTTP API(`/api/v1`)、SSE 与 worker 入口。默认 `--role all` 单进程运行;部署按 `api`、`worker`(IO)、`worker:agent` 分进程。OpenAPI 见 `/api/openapi.json`、`/api/docs`。业务编排和写入放在模块 service,确定性规则放在 `@umt/domain`。
|
||||
|
||||
## 模块骨架(W2)
|
||||
## 模块骨架
|
||||
|
||||
```
|
||||
```text
|
||||
src/
|
||||
main.ts 进程入口:按 --role 装配
|
||||
context.ts bootstrap():ServerContext、任务注册表(模块 + 空壳兜底)、outbox 路由合并、连接引擎
|
||||
http/app.ts createApp():系统路由 + 逐个挂 SERVER_MODULES 的 registerRoutes
|
||||
worker/index.ts startWorker():引擎 worker + OutboxRelay + 各模块 start()
|
||||
hooks/ 链式钩子注册表与调用入口(ADR-006)
|
||||
main.ts 按 --role 装配进程
|
||||
context.ts 装配 ServerContext、实际任务注册表与 outbox 路由
|
||||
http/app.ts 系统路由 + 模块 registerRoutes
|
||||
worker/index.ts 任务 worker、OutboxRelay、模块后台循环
|
||||
authorization/grants.ts 共用计划绑定、持续授权范围与预算检查
|
||||
sandbox/ 外部字节解析子进程与 seccomp 策略
|
||||
modules/
|
||||
types.ts ServerModule / ServerContext / OutboxWriter / SecretStore / SettingsReader(共享,不随子任务改)
|
||||
types.ts ServerModule / ServerContext 等装配契约
|
||||
index.ts SERVER_MODULES 清单
|
||||
auth-settings/ W2-A(provided.ts 提供 secrets / settings 实现)
|
||||
sources-identity/ W2-B
|
||||
acquisition-organize/ W2-C
|
||||
file-manager/ W5-A:/files/*(浏览、mkdir/rename、move/copy/organize 两阶段、回收站、identify/probe/scrape、download),
|
||||
见 docs/runbook/files.md
|
||||
agent/ W2-D:capability-registry.ts(authorize / audit / 幂等键 / 操作账本)、会话 API、runAgent、resolveDecision
|
||||
(见 docs/runbook/agent.md;agent 模块可以 import 其它模块的 service 导出,反向不行)
|
||||
decision-hooks.ts 待处理事项的实际业务处理器
|
||||
auth-settings/ 鉴权、密钥与设置
|
||||
sources-identity/ 站点、资源、识别与元数据
|
||||
acquisition-organize/ 订阅、收录、下载、整理与刮削
|
||||
file-manager/ 文件浏览与操作
|
||||
agent/ Agent 会话、交互与能力调用编排
|
||||
… 其余模块见 SERVER_MODULES
|
||||
```
|
||||
|
||||
**分工规则:各 W2 子任务只改自己的 `modules/<area>/` 目录,以及 `packages/contracts`、`packages/db` 中本域的文件**(新迁移按编号顺延,开工前在群里认领编号)。`modules/types.ts`、`modules/index.ts`、`context.ts`、`http/app.ts`、`worker/index.ts`、`main.ts` 是共享装配,确需改动时单独提交并通知其他子任务;`http/auth.ts`、`http/sse.ts` 归 W2-A。跨模块只通过 DB 表 + `outbox_event`(事件类型在 contracts/events.ts)+ contracts 类型耦合,不互相 import 对方模块。
|
||||
跨模块的同步业务操作显式调用 service;可靠的异步后续动作随业务事务写 outbox。路由和能力处理器共用业务入口,service 不依赖 HTTP 路由或 Agent 会话编排。共用语义类型直接复用,有实际取值差异才用 `@umt/mapping`。完整边界、状态归属和抽象约定见 [开发指南](../../docs/runbook/dev.md#工程设计与适度抽象)。
|
||||
|
||||
一个模块长这样(`modules/<area>/index.ts` 导出,目录内自由拆 `routes.ts` / `service.ts` / `handlers/*.ts`):
|
||||
## 模块装配
|
||||
|
||||
```ts
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { capabilitySpec, EventType } from "@umt/contracts";
|
||||
import { defineTask } from "@umt/tasks";
|
||||
import { requireScope } from "../../http/auth.ts";
|
||||
import type { ServerModule } from "../types.ts";
|
||||
`modules/<area>/index.ts` 导出 `ServerModule`,按实际需求实现:
|
||||
|
||||
export const sourcesIdentityModule: ServerModule = {
|
||||
name: "sources-identity",
|
||||
// 路径相对 /api/v1,已过 API Key 鉴权;每个路由自己声明 scope
|
||||
registerRoutes(app, ctx) {
|
||||
app.use("/sites/*", requireScope("read"));
|
||||
app.openapi(createRoute({ method: "get", path: "/sites", /* … */ }), async (c) => c.json(/* … */));
|
||||
},
|
||||
// 同名任务只能由一个模块注册;未注册的 §3.1-8 任务由 @umt/tasks 空壳兜底
|
||||
registerTasks(registry, ctx) {
|
||||
registry.register(defineTask({ name: "resolveObservation", version: 1, /* … */ handler: async (t) => {
|
||||
await ctx.db.transaction().execute(async (trx) => {
|
||||
// …写业务表,同事务发事件(payload 按 contracts 校验)
|
||||
await ctx.outbox.emit(trx, EventType.releaseResolved, payload, { idempotencyKey: `release.resolved:${id}` });
|
||||
});
|
||||
} }));
|
||||
},
|
||||
// 消费别的模块发出的事件:type → 任务;同一 type 只能一个模块认领
|
||||
outboxRoutes: () => ({ "release.resolved": (ev) => ({ task: "evaluateAcquisition", input: { sourceReleaseId: (ev.payload as { sourceReleaseId: string }).sourceReleaseId } }) }),
|
||||
// 能力:规格一律取 contracts,处理器调本模块服务
|
||||
registerCapabilities(registry, ctx) {
|
||||
registry.register(capabilitySpec("release.search"), async (input, c) => ({ items: [], coverage: [] }));
|
||||
},
|
||||
// worker 角色的后台循环(qB 轮询、Feed 调度…);引擎与中继已启动
|
||||
async start(ctx) {},
|
||||
async stop() {},
|
||||
};
|
||||
```
|
||||
| 入口 | 职责 |
|
||||
|---|---|
|
||||
| `registerRoutes(app, ctx)` | 注册相对 `/api/v1` 的路由,声明 scope、校验输入、调用 service |
|
||||
| `registerTasks(registry, ctx)` | 用 `defineTask` 注册真实 handler;任务名和版本不能重复,没有空壳兜底 |
|
||||
| `registerCapabilities(registry, ctx)` | 规格取自 contracts,处理器调用 service |
|
||||
| `outboxRoutes(ctx)` | 事件 → 任务、任务数组或 null;生命周期事件允许多个订阅方,内部流程事件只有一个认领方 |
|
||||
| `start(ctx)` / `stop()` | 后台循环与回收;需要恢复的长任务进入持久队列 |
|
||||
|
||||
`ServerContext` 字段:`db`(Kysely<DB>)、`config`、`logger`(pino,自动脱敏)、`events`(SSE 总线 `EventBusPort`)、`engine`(TaskEngine:enqueue/signal/cancel/get/list;register* 阶段不要调用)、`outbox`(`emit(trx, type, payload, {idempotencyKey})` + worker 的 `relay`)、`secrets`(SecretStore,W2-A 实现前调用会抛错)、`settings`(读 settings 表并合并缺省)、`capabilities`(CapabilityRegistry)。domain ↔ contracts 的取值转换一律用 `@umt/mapping`。
|
||||
`ServerContext` 提供 `db`、`config`、`logger`、`events`、`engine`、`outbox`、`secrets`、`settings`、`capabilities`。注册阶段只做装配,不要使用尚未连接的引擎。`all` 连接时顺带迁移引擎;分进程部署先执行 `--role migrate`,再以 `runMigrations:false` 连接。
|
||||
|
||||
角色:`all` 单进程时连接引擎会顺带跑引擎迁移;`api` / `worker` 分进程部署时由 `--role migrate` 预先迁移(db + openworkflow),两个进程都以 `runMigrations:false` 连接。
|
||||
业务写入与 `ctx.outbox.emit(trx, type, payload, { idempotencyKey })` 同事务提交。payload 在 contracts 定义并校验,订阅任务按 ID 回查业务记录、幂等执行。引擎步骤与租约不替代业务表的状态。
|
||||
|
||||
## 如何写一个功能模块(ADR-006)
|
||||
## 新增功能模块
|
||||
|
||||
外围功能(补字幕这类)按下面的约定接进来,不做插件系统、不做开关。样板是 `modules/subtitles/`。
|
||||
以 `modules/subtitles/` 为样板,只接入实际需要的入口:
|
||||
|
||||
1. **登记 manifest**:在 `packages/contracts/src/features.ts` 的 `FEATURES` 里加一份(名称、说明、怎么用、订阅的事件、能力、页面入口、插槽、钩子)。它会进智能体系统提示「能力地图」末尾的功能清单;`test/modules.test.ts` 核对模块与清单一一对应。
|
||||
2. **建模块**:`modules/<id>/index.ts` 导出 `ServerModule`,`name` 与 manifest id 相同,写 `manifest: featureManifest("<id>")`,加进 `modules/index.ts`。
|
||||
3. **订阅广播事件**(事后通知,异步、至少一次):`outboxRoutes` 里写 `"<事件>": (ev) => 任务 | 任务数组 | null`。生命周期事件(`LIFECYCLE_EVENTS`)可以多个模块同时订阅;payload 用 `parseEventPayload` 取,按 ID 回查业务表;订阅任务要幂等(同一事件可能投递多次)。
|
||||
```ts
|
||||
outboxRoutes: (ctx) => ({
|
||||
"organization.finished": (ev) => {
|
||||
const p = parseEventPayload("organization.finished", ev.payload);
|
||||
return p?.outcome === "succeeded" ? { task: "myFollowUp", input: { recordId: p.recordId } } : null;
|
||||
},
|
||||
}),
|
||||
```
|
||||
4. **注册链式钩子**(同步改写 / 拦截,挂点见 `packages/contracts/src/hooks.ts`):
|
||||
```ts
|
||||
registerHooks(hooks) {
|
||||
hooks.rewrite("organize.rename", (name, c) => (c.mediaType === "movie" ? name.replace(/ - 1080p/, "") : undefined));
|
||||
hooks.intercept("organize.intercept", (c) => (c.files.some((f) => f.dst.includes("/Sample/")) ? "目标在 Sample 目录" : null));
|
||||
},
|
||||
```
|
||||
钩子可能在业务事务里执行:只做内存里的判断,不发外部请求、不写库;缺省 2 秒超时,抛错 / 超时只跳过自己。
|
||||
5. **自己发事件**:业务节点在事务里 `ctx.outbox.emit(trx, "<事件>", payload, { idempotencyKey: lifecycleKey("<事件>", id) })`;新事件先在 `contracts/events.ts` 加类型、payload schema,并登记进 `LIFECYCLE_EVENTS`。
|
||||
6. **前端入口**:在 `apps/web/app/features/<id>.tsx` 用插槽注册(作品页操作区 / 面板区、整理记录行操作、侧栏、设置分区),在 `features/index.ts` 调一次;默认直接显示。
|
||||
7. **智能体能力**(可选):照常在 contracts 定义能力、在 `registerCapabilities` 挂处理器,并登记 Skill 与能力地图(见仓库根 CLAUDE.md)。
|
||||
1. 在 `packages/contracts/src/features.ts` 登记 manifest(名称、用法、事件、能力、页面与插槽)。它进入 Agent 功能清单,由 `test/modules.test.ts` 核对。
|
||||
2. 导出 `ServerModule`,设置 `manifest: featureManifest("<id>")`,加入 `modules/index.ts`。
|
||||
3. 需要事后动作时订阅生命周期事件,用 `parseEventPayload` 读取;自己发新事件时在 contracts 登记类型、schema 与 `LIFECYCLE_EVENTS`。
|
||||
4. 需要前端入口时在 `apps/web/app/features/<id>.tsx` 注册插槽并加入 `features/index.ts`,默认直接显示。
|
||||
5. 新增 Agent 能力时同时登记 Skill 与能力地图,见根目录 `CLAUDE.md`。
|
||||
|
||||
同步规则直接在所属 service 实现。目前没有通用链式钩子;`decision-hooks.ts` 只处理已有待处理事项的业务动作。功能默认启用,无需另建插件系统或开关。
|
||||
|
||||
开发、测试和容器说明见 [开发手册](../../docs/runbook/dev.md) 与 [部署手册](../../docs/runbook/deploy.md)。
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "@umt/server",
|
||||
"version": "0.3.44",
|
||||
"exports": { ".": "./src/main.ts" },
|
||||
"tasks": {
|
||||
"dev": "deno run -A --watch --env-file=../../.env src/main.ts",
|
||||
"start": "deno run --allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,cat,deno --env-file=../../.env src/main.ts",
|
||||
"compile": "deno compile --frozen --node-modules-dir=none --exclude-unused-npm --allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,cat,/app/umt-server --include ../../packages/db/migrations --include ../../packages/agent/skills --output ../../dist/umt-server src/main.ts",
|
||||
"test": "deno run -A npm:vitest run",
|
||||
"typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json"
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,24 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/main.ts"
|
||||
},
|
||||
"version": "0.3.44",
|
||||
"scripts": {
|
||||
"dev": "bun --watch --env-file=../../.env src/main.ts",
|
||||
"start": "bun --env-file=../../.env src/main.ts",
|
||||
"compile": "bun ../../scripts/compile.ts",
|
||||
"test": "bun x --bun vitest run",
|
||||
"typecheck": "bun x --bun tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@umt/contracts": "workspace:*",
|
||||
"@umt/adapters": "workspace:*",
|
||||
"@umt/mapping": "workspace:*",
|
||||
"@umt/tasks": "workspace:*",
|
||||
"@umt/agent": "workspace:*",
|
||||
"@umt/db": "workspace:*",
|
||||
"@umt/runtime": "workspace:*",
|
||||
"@umt/domain": "workspace:*",
|
||||
"@umt/testkit": "workspace:*"
|
||||
}
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "*.wasm" {
|
||||
const path: string;
|
||||
export default path;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
|
||||
// 能力执行共用的计划绑定和持续授权检查;不依赖 Agent 会话、方案选择或审批交互。
|
||||
|
||||
/** 能力 → 可覆盖它的授权动作(策略发布生成 policy.<kind> 持续授权;"*" 全部) */
|
||||
export const GRANT_ACTIONS: Record<string, readonly string[]> = {
|
||||
"identity.resolve": ["identity.resolve", "policy.identity"],
|
||||
"download.submit": ["download.submit", "policy.acquisition"],
|
||||
"organize.execute": ["organize.execute", "policy.acquisition", "policy.organize"],
|
||||
"download.recheck": ["download.recheck", "policy.automation"],
|
||||
"task.retry": ["task.retry", "task.*"],
|
||||
"task.cancel": ["task.cancel", "task.*"],
|
||||
"policy.apply": ["policy.apply"],
|
||||
// 集数映射兜底(批次 5):只认专门的持续授权,不随身份识别策略放开
|
||||
"episode_mapping.apply_rule": ["episode_mapping.apply_rule"],
|
||||
// VIS-1:坐标操作与视觉子流程和 browser.interact 同一档(操作原站页面),同一条持续授权覆盖
|
||||
"browser.click_at": ["browser.click_at", "browser.interact"],
|
||||
"browser.type_at": ["browser.type_at", "browser.interact"],
|
||||
"browser.scroll": ["browser.scroll", "browser.interact"],
|
||||
"browser.solve_challenge": ["browser.solve_challenge", "browser.interact"],
|
||||
};
|
||||
|
||||
/** 单次审批绑定的计划类型与输入里的计划 ID 字段 */
|
||||
export const APPROVAL_PLAN: Record<string, { planType: string; field: string }> = {
|
||||
"download.submit": { planType: "acquisition_plan", field: "planId" },
|
||||
"organize.execute": { planType: "organize_plan", field: "planId" },
|
||||
"policy.apply": { planType: "policy_revision", field: "policyRevisionId" },
|
||||
};
|
||||
|
||||
export interface PlanBinding {
|
||||
payloadHash: string;
|
||||
revision: number;
|
||||
mediaEntityId: string | null;
|
||||
mediaKind: string | null;
|
||||
bytes: number | null;
|
||||
}
|
||||
|
||||
const sha = (s: string) => createHash("sha256").update(s, "utf8").digest("hex");
|
||||
|
||||
/** 读计划当前的 payload hash / revision / 目标作品(审批绑定与范围校验共用) */
|
||||
export async function planBinding(db: Kysely<DB>, planType: string, planId: string): Promise<PlanBinding | null> {
|
||||
if (planType === "acquisition_plan") {
|
||||
const r = await db
|
||||
.selectFrom("acquisition_plan as p")
|
||||
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
|
||||
.leftJoin("media_entity as e", (j) =>
|
||||
j.onRef("e.id", "=", sql<string>`coalesce(s.media_entity_id, (p.evaluation->>'mediaEntityId')::uuid)`),
|
||||
)
|
||||
.select(["p.payload_hash", "p.expected_revision", "e.id as entity_id", "e.kind", "p.reserved_bytes"])
|
||||
.where("p.id", "=", planId)
|
||||
.executeTakeFirst();
|
||||
return r
|
||||
? {
|
||||
payloadHash: r.payload_hash,
|
||||
revision: r.expected_revision,
|
||||
mediaEntityId: r.entity_id,
|
||||
mediaKind: r.kind,
|
||||
bytes: Number(r.reserved_bytes),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if (planType === "organize_plan") {
|
||||
const r = await db
|
||||
.selectFrom("artifact")
|
||||
.select(["content_hash", "content"])
|
||||
.where("id", "=", planId)
|
||||
.where("kind", "=", "plan")
|
||||
.executeTakeFirst();
|
||||
if (!r) return null;
|
||||
const c = (r.content ?? {}) as { mediaEntityId?: string | null; mediaKind?: string | null };
|
||||
return {
|
||||
payloadHash: r.content_hash,
|
||||
revision: 1,
|
||||
mediaEntityId: c.mediaEntityId ?? null,
|
||||
mediaKind: c.mediaKind ?? null,
|
||||
bytes: null,
|
||||
};
|
||||
}
|
||||
if (planType === "policy_revision") {
|
||||
const r = await db
|
||||
.selectFrom("policy_revision")
|
||||
.select(["revision", "rules", "scope"])
|
||||
.where("id", "=", planId)
|
||||
.executeTakeFirst();
|
||||
return r
|
||||
? {
|
||||
payloadHash: sha(JSON.stringify({ rules: r.rules, scope: r.scope })),
|
||||
revision: r.revision,
|
||||
mediaEntityId: null,
|
||||
mediaKind: null,
|
||||
bytes: null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 持续授权是否覆盖本能力(不含单次审批与会话内审批):action 覆盖、active 未过期、绑定策略仍发布、
|
||||
* 对象范围与每日预算。download.plan 用它判定 requiresApproval,authorize 用它放行。
|
||||
*/
|
||||
export async function coveringGrant(
|
||||
db: Kysely<DB>,
|
||||
capabilityId: string,
|
||||
target: PlanBinding | null,
|
||||
now: Date,
|
||||
): Promise<{ grantId: string } | { reasons: string[] }> {
|
||||
const d = { id: capabilityId };
|
||||
const actions = [...(GRANT_ACTIONS[d.id] ?? [d.id]), "*"];
|
||||
const grants = await db
|
||||
.selectFrom("automation_grant as g")
|
||||
.leftJoin("policy_revision as p", "p.id", "g.policy_revision_id")
|
||||
.select(["g.id", "g.action", "g.scope", "g.budget", "g.policy_revision_id", "p.status as policy_status"])
|
||||
.where("g.status", "=", "active")
|
||||
.where("g.action", "in", actions)
|
||||
.where((eb) => eb.or([eb("g.expires_at", "is", null), eb("g.expires_at", ">", now)]))
|
||||
.orderBy("g.created_at", "desc")
|
||||
.execute();
|
||||
const reasons: string[] = [];
|
||||
for (const g of grants) {
|
||||
if (g.policy_revision_id && g.policy_status !== "published") {
|
||||
reasons.push(`授权 ${g.id} 绑定的策略版本已不是发布状态`);
|
||||
continue;
|
||||
}
|
||||
const scope = (g.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[]; capabilities?: string[] };
|
||||
if (scope.capabilities?.length && !scope.capabilities.includes(d.id)) {
|
||||
reasons.push(`授权 ${g.id} 不含能力 ${d.id}`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
scope.mediaEntityIds?.length &&
|
||||
(!target?.mediaEntityId || !scope.mediaEntityIds.includes(target.mediaEntityId))
|
||||
) {
|
||||
reasons.push(`授权 ${g.id} 的对象范围不含目标作品`);
|
||||
continue;
|
||||
}
|
||||
if (scope.mediaKinds?.length && target?.mediaKind && !kindCovered(scope.mediaKinds, target.mediaKind)) {
|
||||
reasons.push(`授权 ${g.id} 的媒体类型范围不含 ${target.mediaKind}`);
|
||||
continue;
|
||||
}
|
||||
const budget = (g.budget ?? {}) as { maxInvocationsPerDay?: number; maxBytesPerDay?: number };
|
||||
if (budget.maxInvocationsPerDay !== undefined || budget.maxBytesPerDay !== undefined) {
|
||||
const day = new Date(now);
|
||||
day.setHours(0, 0, 0, 0);
|
||||
const used = await sql<{ n: number }>`
|
||||
select count(*)::int as n from capability_invocation
|
||||
where grant_id = ${g.id} and status = 'succeeded' and created_at >= ${day}`.execute(db);
|
||||
if (budget.maxInvocationsPerDay !== undefined && (used.rows[0]?.n ?? 0) >= budget.maxInvocationsPerDay) {
|
||||
reasons.push(`授权 ${g.id} 今日次数预算已用尽`);
|
||||
continue;
|
||||
}
|
||||
if (budget.maxBytesPerDay !== undefined && d.id === "download.submit") {
|
||||
const bytes = await sql<{ b: string }>`
|
||||
select coalesce(sum(p.reserved_bytes), 0)::bigint as b
|
||||
from capability_invocation ci join acquisition_plan p on p.id::text = ci.input->>'planId'
|
||||
where ci.grant_id = ${g.id} and ci.status = 'succeeded' and ci.created_at >= ${day}`.execute(db);
|
||||
if (Number(bytes.rows[0]?.b ?? 0) + (target?.bytes ?? 0) > budget.maxBytesPerDay) {
|
||||
reasons.push(`授权 ${g.id} 今日字节预算不足`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { grantId: g.id };
|
||||
}
|
||||
return { reasons };
|
||||
}
|
||||
|
||||
/** 策略范围用订阅口径(movie / series / variety),作品用 media_entity.kind(movie / tv / variety …) */
|
||||
function kindCovered(kinds: readonly string[], entityKind: string): boolean {
|
||||
if (kinds.includes(entityKind)) return true;
|
||||
if (entityKind !== "movie" && entityKind !== "variety" && kinds.includes("series")) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -55,8 +55,8 @@ export const ConfigSchema = z.object({
|
||||
/** Agent JSONL 会话目录;缺省 DATA_DIR/agent-sessions(api 与 worker 必须共享同一目录) */
|
||||
AGENT_SESSION_DIR: z.string().min(1).optional(),
|
||||
// ── 文件操作挂死隔离(adapters fsproxy,对标 MP fsproxy)──
|
||||
/** auto:能起 deno 子进程就把单个文件操作放进执行子进程(可 SIGKILL 回收),否则进程内看门狗;inprocess 强制进程内 */
|
||||
FS_PROXY_MODE: z.enum(["auto", "subprocess", "inprocess"]).default("auto"),
|
||||
/** auto:能起 bun 子进程就把单个文件操作放进执行子进程(可 SIGKILL 回收),否则进程内看门狗;inprocess 强制进程内 */
|
||||
FS_PROXY_MODE: z.enum(["auto", "subprocess", "inprocess"]).default("subprocess"),
|
||||
/** 复制/哈希:进度停滞多久判挂死(ms),超时 SIGTERM→SIGKILL 并以 TIMEOUT 失败 */
|
||||
FS_HANG_TIMEOUT_MS: z.coerce.number().int().min(1000).default(120_000),
|
||||
/** stat/根检查/同卷 move 等快操作的总超时(ms) */
|
||||
@@ -104,7 +104,7 @@ export const ConfigSchema = z.object({
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
/** 从环境变量加载并校验配置;失败时抛出可读错误(不含变量值) */
|
||||
export function loadConfig(env: Record<string, string | undefined> = Deno.env.toObject()): Config {
|
||||
export function loadConfig(env: Record<string, string | undefined> = { ...process.env }): Config {
|
||||
const parsed = ConfigSchema.safeParse(env);
|
||||
if (!parsed.success) {
|
||||
const lines = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`);
|
||||
@@ -117,7 +117,7 @@ export function loadConfig(env: Record<string, string | undefined> = Deno.env.to
|
||||
export const DATA_SUBDIRS = ["agent-sessions", "backups", "exports", "tmp"] as const;
|
||||
|
||||
/** 解析数据目录与会话目录为绝对路径 */
|
||||
export function dataPaths(c: Pick<Config, "DATA_DIR" | "AGENT_SESSION_DIR">, cwd: string = Deno.cwd()) {
|
||||
export function dataPaths(c: Pick<Config, "DATA_DIR" | "AGENT_SESSION_DIR">, cwd: string = process.cwd()) {
|
||||
const dataDir = resolve(cwd, c.DATA_DIR);
|
||||
return {
|
||||
dataDir,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type EventType, LIFECYCLE_EVENTS, parseEventPayload } from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import {
|
||||
createDefaultRegistry,
|
||||
type LogSink,
|
||||
OpenWorkflowEngine,
|
||||
type OutboxRelay,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
} from "@umt/tasks";
|
||||
import type { Logger } from "pino";
|
||||
import type { Config } from "./config.ts";
|
||||
import { HookRegistry } from "./hooks/registry.ts";
|
||||
import type { EventBusPort } from "./http/sse.ts";
|
||||
import { assertCapabilityCount, createCapabilityRegistry } from "./modules/agent/capability-registry.ts";
|
||||
import { createSecretStore, createSettingsReader } from "./modules/auth-settings/provided.ts";
|
||||
@@ -80,8 +78,8 @@ export function tasksLogSink(logger: Logger): LogSink {
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务注册表:各模块注册自己的任务;没人注册的 §3.1-8 任务用 @umt/tasks 的空壳兜底
|
||||
* (outbox 路由到它们时不至于报"未知任务")。同名任务被两个模块注册会在这里报错。
|
||||
* 任务注册表只收录业务模块的真实实现。同名任务被两个模块注册会在这里报错;
|
||||
* 没有注册的任务由 TaskRegistry 明确报 UnknownTaskError,不以占位处理器代替。
|
||||
*/
|
||||
export function buildTaskRegistry(modules: readonly ServerModule[], ctx: ServerContext): TaskRegistry {
|
||||
const registry = new TaskRegistry();
|
||||
@@ -96,7 +94,6 @@ export function buildTaskRegistry(modules: readonly ServerModule[], ctx: ServerC
|
||||
}
|
||||
registry.register(...own.all());
|
||||
}
|
||||
for (const stub of createDefaultRegistry().all()) if (!registry.has(stub.name)) registry.register(stub);
|
||||
return registry;
|
||||
}
|
||||
|
||||
@@ -153,13 +150,6 @@ export function collectOutboxRoutes(
|
||||
return routes;
|
||||
}
|
||||
|
||||
/** 链式钩子注册表:按模块顺序调 registerHooks,执行顺序即注册顺序(ADR-006) */
|
||||
export function buildHookRegistry(modules: readonly ServerModule[], ctx: ServerContext): HookRegistry {
|
||||
const hooks = new HookRegistry(ctx.logger?.child({ component: "hooks" }));
|
||||
for (const m of modules) m.registerHooks?.(hooks.forModule(m.name), ctx);
|
||||
return hooks;
|
||||
}
|
||||
|
||||
export interface BootstrapOptions {
|
||||
role: ServerRole;
|
||||
config: Config;
|
||||
@@ -204,7 +194,6 @@ export async function bootstrap(opts: BootstrapOptions): Promise<Bootstrapped> {
|
||||
configureStorage(ctx);
|
||||
const tasks = buildTaskRegistry(opts.modules, ctx);
|
||||
for (const m of opts.modules) m.registerCapabilities?.(ctx.capabilities, ctx);
|
||||
ctx.hooks = buildHookRegistry(opts.modules, ctx);
|
||||
// 装配了 agent 模块(即完整模块清单)时,首批 14 个能力必须全部挂上处理器
|
||||
if (opts.modules.some((m) => m.name === "agent")) assertCapabilityCount(ctx.capabilities);
|
||||
engine.attach(
|
||||
|
||||
+26
-70
@@ -1,75 +1,28 @@
|
||||
// CF-2 出口统一:每条对外 HTTP 请求走哪条出口由代码显式决定,不再隐式继承容器的 HTTP(S)_PROXY。
|
||||
//
|
||||
// Deno 2.9 的语义(2026-09-30 本机实测,见 docs/runbook/sites.md「出口」):
|
||||
// - `fetch` 缺省客户端与 `Deno.createHttpClient({})` 都会读 HTTP_PROXY / HTTPS_PROXY / ALL_PROXY(大小写都认)与 NO_PROXY;
|
||||
// 读取发生在**客户端创建时**(缺省客户端在进程第一次 fetch 时创建);
|
||||
// - `createHttpClient` 没有"不走代理"的选项,`NO_PROXY=*` 也不认;显式 `proxy` 会排在环境变量代理前面,
|
||||
// 但 NO_PROXY 命中的主机连显式代理也绕过;
|
||||
// - 所以直连客户端的做法是:创建那一刻把这几个环境变量临时拿掉(同步调用,同一个 isolate 内不会被别的代码插进来),建完立即还原。
|
||||
// 代理客户端同样在拿掉环境变量后创建,保证只走给定的代理。
|
||||
//
|
||||
// 出口表:
|
||||
// - 站点请求(适配器、RSS、登录、用户数据):站点开"使用代理"且设了代理地址 → 该代理;否则直连(siteClient);
|
||||
// - TMDB、Brave、图片缓存等外网请求:照旧用环境变量里的代理(行为不变),但改成显式读 env 建客户端(externalFetch)。
|
||||
|
||||
const PROXY_ENV = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] as const;
|
||||
const NO_PROXY_ENV = ["NO_PROXY", "no_proxy"] as const;
|
||||
|
||||
/** 进程启动时的代理环境变量快照(外网请求显式使用;之后的临时改动不影响) */
|
||||
const ENV_SNAPSHOT: Record<string, string | undefined> = (() => {
|
||||
const out: Record<string, string | undefined> = {};
|
||||
try {
|
||||
for (const k of [...PROXY_ENV, ...NO_PROXY_ENV]) out[k] = Deno.env.get(k);
|
||||
} catch {
|
||||
// 没有 env 权限:当作没有代理
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
/** 在"没有代理环境变量"的状态下同步执行 fn(建 HttpClient 用),结束立即还原 */
|
||||
function withoutProxyEnv<T>(fn: () => T): T {
|
||||
const saved = new Map<string, string>();
|
||||
try {
|
||||
for (const k of [...PROXY_ENV, ...NO_PROXY_ENV]) {
|
||||
const v = Deno.env.get(k);
|
||||
if (v !== undefined) {
|
||||
saved.set(k, v);
|
||||
Deno.env.delete(k);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 没有 env 权限时也没有可读的代理变量,直接建
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
for (const [k, v] of saved) Deno.env.set(k, v);
|
||||
}
|
||||
// 每次请求显式指定 Bun 的 proxy,直连使用proxy: false以禁用环境代理。
|
||||
export interface HttpClient {
|
||||
proxy: false | { url: string; respectNoProxy: false };
|
||||
close(): void;
|
||||
}
|
||||
|
||||
let direct: Deno.HttpClient | null = null;
|
||||
const proxied = new Map<string, Deno.HttpClient>();
|
||||
|
||||
/** 明确直连的客户端(不读任何代理环境变量) */
|
||||
export function directHttpClient(): Deno.HttpClient {
|
||||
direct ??= withoutProxyEnv(() => Deno.createHttpClient({}));
|
||||
const direct: HttpClient = { proxy: false, close() {} };
|
||||
const proxied = new Map<string, HttpClient>();
|
||||
const ENV_SNAPSHOT = { ...process.env };
|
||||
export function directHttpClient(): HttpClient {
|
||||
return direct;
|
||||
}
|
||||
|
||||
/** 只走给定代理的客户端(同一地址复用) */
|
||||
export function proxyHttpClient(url: string): Deno.HttpClient {
|
||||
let c = proxied.get(url);
|
||||
if (!c) {
|
||||
c = withoutProxyEnv(() => Deno.createHttpClient({ proxy: { url } }));
|
||||
proxied.set(url, c);
|
||||
export function proxyHttpClient(url: string): HttpClient {
|
||||
let client = proxied.get(url);
|
||||
if (!client) {
|
||||
client = { proxy: { url, respectNoProxy: false }, close() {} };
|
||||
proxied.set(url, client);
|
||||
}
|
||||
return c;
|
||||
return client;
|
||||
}
|
||||
|
||||
/** 用指定客户端发请求的 fetch */
|
||||
export function fetchVia(client: Deno.HttpClient): typeof globalThis.fetch {
|
||||
export function fetchVia(client: HttpClient): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
||||
return ((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
globalThis.fetch(input, { ...init, client } as RequestInit)) as typeof globalThis.fetch;
|
||||
globalThis.fetch(input, { ...init, proxy: client.proxy } as RequestInit)) as (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
}
|
||||
|
||||
/** 站点请求的出口:开代理 → 设置里的站点代理地址,没填则用进程的 HTTPS_PROXY / HTTP_PROXY;都没有 → direct */
|
||||
@@ -98,7 +51,7 @@ export function egressKey(e: SiteEgress): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function siteFetchFor(e: SiteEgress): typeof globalThis.fetch {
|
||||
export function siteFetchFor(e: SiteEgress): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
||||
return fetchVia(e.mode === "proxy" && e.proxyUrl ? proxyHttpClient(e.proxyUrl) : directHttpClient());
|
||||
}
|
||||
|
||||
@@ -128,7 +81,10 @@ export function envProxyFor(target: URL, env: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
/** 外网请求用的 fetch:按环境变量显式选代理 / 直连(与之前隐式继承的行为一致) */
|
||||
export const externalFetch: typeof globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
export const externalFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> = ((
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => {
|
||||
let target: URL | null = null;
|
||||
try {
|
||||
target = new URL(input instanceof Request ? input.url : String(input));
|
||||
@@ -137,5 +93,5 @@ export const externalFetch: typeof globalThis.fetch = ((input: RequestInfo | URL
|
||||
}
|
||||
const proxy = target ? envProxyFor(target) : null;
|
||||
const client = proxy ? proxyHttpClient(proxy) : directHttpClient();
|
||||
return globalThis.fetch(input, { ...init, client } as RequestInit);
|
||||
}) as typeof globalThis.fetch;
|
||||
return fetchVia(client)(input, init);
|
||||
}) as (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { HookPoint } from "@umt/contracts";
|
||||
import type { ServerContext } from "../modules/types.ts";
|
||||
import type { HookContext, HookValue, InterceptPoint, RewritePoint } from "./points.ts";
|
||||
import type { HookRejection } from "./registry.ts";
|
||||
|
||||
export type * from "./points.ts";
|
||||
export {
|
||||
DEFAULT_HOOK_TIMEOUT_MS,
|
||||
type HookOptions,
|
||||
type HookRegistrar,
|
||||
HookRegistry,
|
||||
type HookRejection,
|
||||
} from "./registry.ts";
|
||||
|
||||
// 业务节点调用钩子的入口。ctx.hooks 由 bootstrap 装配;测试里手搭的上下文没有它,等同于没有注册。
|
||||
|
||||
/** 这个挂点有没有钩子(上下文构造代价大时先问一下) */
|
||||
export function hasHook(ctx: Pick<ServerContext, "hooks">, point: HookPoint): boolean {
|
||||
return ctx.hooks?.has(point) ?? false;
|
||||
}
|
||||
|
||||
/** 改写:没有钩子时原样返回同一个值;accept 判定单个钩子的结果能不能用(不能用的跳过) */
|
||||
export async function rewriteHook<P extends RewritePoint>(
|
||||
ctx: Pick<ServerContext, "hooks">,
|
||||
point: P,
|
||||
value: HookValue<P>,
|
||||
context: HookContext<P> | (() => HookContext<P>),
|
||||
accept?: (v: HookValue<P>) => boolean,
|
||||
): Promise<HookValue<P>> {
|
||||
const h = ctx.hooks;
|
||||
if (!h?.has(point)) return value;
|
||||
return h.runRewrite(point, value, typeof context === "function" ? context() : context, accept);
|
||||
}
|
||||
|
||||
/** 拦截:返回第一个拒绝,没有钩子或都放行时返回 null */
|
||||
export async function interceptHook<P extends InterceptPoint>(
|
||||
ctx: Pick<ServerContext, "hooks">,
|
||||
point: P,
|
||||
context: HookContext<P> | (() => HookContext<P>),
|
||||
): Promise<HookRejection | null> {
|
||||
const h = ctx.hooks;
|
||||
if (!h?.has(point)) return null;
|
||||
return h.runIntercept(point, typeof context === "function" ? context() : context);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import type { HOOK_POINTS, HookPoint } from "@umt/contracts";
|
||||
import type { ParsedRelease } from "@umt/domain";
|
||||
|
||||
// 链式钩子挂点的入参 / 返回值类型(挂点清单与语义在 contracts/hooks.ts,ADR-006)。
|
||||
// rewrite 挂点:value 是要改写的值,context 只读;intercept 挂点只有 context。
|
||||
|
||||
/** 资源选择的候选:只能重排或过滤,不能加入新候选(按 sourceReleaseId 映射回原资源) */
|
||||
export interface SelectionCandidate {
|
||||
sourceReleaseId: string;
|
||||
title: string;
|
||||
parsed: ParsedRelease;
|
||||
}
|
||||
|
||||
export interface HookPointTypes {
|
||||
"name.recognize": {
|
||||
value: ParsedRelease;
|
||||
context: {
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
/** 来源资源;按名称识别整理文件时为 null */
|
||||
sourceReleaseId: string | null;
|
||||
trigger: string;
|
||||
};
|
||||
};
|
||||
"organize.rename": {
|
||||
/** 目标文件名(不含目录);返回值不能含路径分隔符 */
|
||||
value: string;
|
||||
context: {
|
||||
targetPath: string;
|
||||
sourcePath: string;
|
||||
mediaEntityId: string;
|
||||
mediaType: "movie" | "tv";
|
||||
season: number | null;
|
||||
episodes: { start: number; end: number } | null;
|
||||
};
|
||||
};
|
||||
"organize.intercept": {
|
||||
value: never;
|
||||
context: {
|
||||
mediaEntityId: string;
|
||||
mediaType: "movie" | "tv";
|
||||
action: string;
|
||||
trigger: string;
|
||||
downloadTaskId: string | null;
|
||||
files: { src: string; dst: string; kind: string; role: "main" | "sidecar" }[];
|
||||
};
|
||||
};
|
||||
"resource.selection": {
|
||||
value: SelectionCandidate[];
|
||||
context: { mediaEntityId: string; subscriptionId: string | null; slotId: string | null };
|
||||
};
|
||||
"subscription.completion.check": {
|
||||
value: never;
|
||||
context: {
|
||||
subscriptionId: string;
|
||||
mediaEntityId: string | null;
|
||||
mediaKind: string;
|
||||
/** 本次判定完成的原因(slot_satisfied / download_accepted) */
|
||||
reason: string;
|
||||
progress: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type KindOf<P extends HookPoint> = (typeof HOOK_POINTS)[P]["kind"];
|
||||
export type RewritePoint = { [P in HookPoint]: KindOf<P> extends "rewrite" ? P : never }[HookPoint];
|
||||
export type InterceptPoint = { [P in HookPoint]: KindOf<P> extends "intercept" ? P : never }[HookPoint];
|
||||
|
||||
export type HookValue<P extends HookPoint> = HookPointTypes[P]["value"];
|
||||
export type HookContext<P extends HookPoint> = Readonly<HookPointTypes[P]["context"]>;
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
/** 返回新值;返回 undefined 表示不改 */
|
||||
export type RewriteHook<P extends RewritePoint> = (
|
||||
value: HookValue<P>,
|
||||
context: HookContext<P>,
|
||||
) => MaybePromise<HookValue<P> | undefined>;
|
||||
/** 返回拒绝原因;返回 null / undefined 表示放行 */
|
||||
export type InterceptHook<P extends InterceptPoint> = (
|
||||
context: HookContext<P>,
|
||||
) => MaybePromise<string | null | undefined>;
|
||||
@@ -1,126 +0,0 @@
|
||||
import { HOOK_POINTS, type HookPoint } from "@umt/contracts";
|
||||
import type { Logger } from "pino";
|
||||
import type { HookContext, HookValue, InterceptHook, InterceptPoint, RewriteHook, RewritePoint } from "./points.ts";
|
||||
|
||||
// 链式钩子(ADR-006,参考 MoviePilot ChainEventType):模块启动时注册,业务节点同步调用。
|
||||
// - 按注册顺序执行(模块顺序 = SERVER_MODULES 顺序,同一模块内按调用顺序);
|
||||
// - 每个钩子单独限时(缺省 2 秒),超时或抛错只记日志、跳过这个钩子,不影响主流程;
|
||||
// - 没有注册时 has() 为 false,调用方直接用原值(零开销)。
|
||||
// 钩子可能在业务事务里被调用:只做内存里的判断与改写,不要发长耗时外部请求、不要写库。
|
||||
|
||||
export const DEFAULT_HOOK_TIMEOUT_MS = 2_000;
|
||||
|
||||
export interface HookOptions {
|
||||
/** 单次执行上限,缺省 DEFAULT_HOOK_TIMEOUT_MS */
|
||||
timeoutMs?: number;
|
||||
/** 日志里显示的名字,缺省 <模块>#<序号> */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface HookRegistrar {
|
||||
rewrite<P extends RewritePoint>(point: P, fn: RewriteHook<P>, opts?: HookOptions): void;
|
||||
intercept<P extends InterceptPoint>(point: P, fn: InterceptHook<P>, opts?: HookOptions): void;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
owner: string;
|
||||
name: string;
|
||||
timeoutMs: number;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 按挂点分表存,调用时由 run* 的类型参数约束
|
||||
fn: (...args: any[]) => unknown;
|
||||
}
|
||||
|
||||
const TIMEOUT = Symbol("timeout");
|
||||
const FAILED = Symbol("failed");
|
||||
|
||||
export interface HookRejection {
|
||||
reason: string;
|
||||
/** 拒绝的钩子名 */
|
||||
by: string;
|
||||
}
|
||||
|
||||
export class HookRegistry {
|
||||
private readonly hooks = new Map<HookPoint, Entry[]>();
|
||||
|
||||
constructor(private readonly logger?: Pick<Logger, "warn">) {}
|
||||
|
||||
/** 给某个模块的注册入口(owner 进日志) */
|
||||
forModule(owner: string): HookRegistrar {
|
||||
const add = (point: HookPoint, kind: "rewrite" | "intercept", fn: Entry["fn"], opts: HookOptions = {}) => {
|
||||
if (HOOK_POINTS[point].kind !== kind)
|
||||
throw new Error(`钩子挂点 ${point} 是 ${HOOK_POINTS[point].kind} 类,不能用 ${kind} 注册`);
|
||||
const list = this.hooks.get(point) ?? [];
|
||||
list.push({
|
||||
owner,
|
||||
name: opts.name ?? `${owner}#${list.filter((e) => e.owner === owner).length + 1}`,
|
||||
timeoutMs: opts.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
|
||||
fn,
|
||||
});
|
||||
this.hooks.set(point, list);
|
||||
};
|
||||
return {
|
||||
rewrite: (point, fn, opts) => add(point, "rewrite", fn, opts),
|
||||
intercept: (point, fn, opts) => add(point, "intercept", fn, opts),
|
||||
};
|
||||
}
|
||||
|
||||
has(point: HookPoint): boolean {
|
||||
return (this.hooks.get(point)?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** 已注册的钩子(挂点 → 钩子名),给功能清单与诊断用 */
|
||||
list(): Record<string, string[]> {
|
||||
return Object.fromEntries([...this.hooks].map(([p, l]) => [p, l.map((e) => e.name)]));
|
||||
}
|
||||
|
||||
/** 依次改写;某个钩子超时 / 抛错 / 结果不被 accept 接受时跳过它,沿用上一个值 */
|
||||
async runRewrite<P extends RewritePoint>(
|
||||
point: P,
|
||||
value: HookValue<P>,
|
||||
context: HookContext<P>,
|
||||
accept?: (v: HookValue<P>) => boolean,
|
||||
): Promise<HookValue<P>> {
|
||||
let cur = value;
|
||||
for (const e of this.hooks.get(point) ?? []) {
|
||||
const r = await this.call(point, e, [cur, context]);
|
||||
if (r === undefined || r === TIMEOUT || r === FAILED) continue;
|
||||
if (accept && !accept(r as HookValue<P>)) {
|
||||
this.logger?.warn({ point, hook: e.name }, "钩子返回的结果不可用,已跳过");
|
||||
continue;
|
||||
}
|
||||
cur = r as HookValue<P>;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** 依次判定;第一个返回原因的钩子拒绝,后面的不再执行 */
|
||||
async runIntercept<P extends InterceptPoint>(point: P, context: HookContext<P>): Promise<HookRejection | null> {
|
||||
for (const e of this.hooks.get(point) ?? []) {
|
||||
const r = await this.call(point, e, [context]);
|
||||
if (typeof r === "string" && r.trim()) return { reason: r.trim(), by: e.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async call(point: HookPoint, e: Entry, args: unknown[]): Promise<unknown> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const r = await Promise.race([
|
||||
Promise.resolve().then(() => e.fn(...args)),
|
||||
new Promise<typeof TIMEOUT>((resolve) => {
|
||||
timer = setTimeout(() => resolve(TIMEOUT), e.timeoutMs);
|
||||
}),
|
||||
]);
|
||||
if (r === TIMEOUT) this.logger?.warn({ point, hook: e.name, timeoutMs: e.timeoutMs }, "钩子超时,已跳过");
|
||||
return r;
|
||||
} catch (err) {
|
||||
this.logger?.warn(
|
||||
{ point, hook: e.name, err: { message: err instanceof Error ? err.message : String(err) } },
|
||||
"钩子出错,已跳过",
|
||||
);
|
||||
return FAILED;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Hono } from "hono";
|
||||
import { serveStatic } from "hono/deno";
|
||||
import { serveStatic } from "hono/bun";
|
||||
|
||||
/**
|
||||
* 托管 React Router SPA 构建产物(build/client):存在的文件直接返回,
|
||||
|
||||
@@ -71,7 +71,7 @@ export function createLogger(opts: LoggerOptions = {}): Logger {
|
||||
pino({ ...options, transport: { target: "pino-pretty", options: { translateTime: "SYS:HH:MM:ss" } } }),
|
||||
);
|
||||
}
|
||||
// 同步写 stdout:进程随后 Deno.exit() 时(迁移/自检子命令、优雅退出)不丢尾部日志
|
||||
// 同步写 stdout:进程随后 process.exit() 时(迁移/自检子命令、优雅退出)不丢尾部日志
|
||||
return redactChildBindings(pino(options, pino.destination({ dest: 1, sync: true })));
|
||||
}
|
||||
|
||||
|
||||
+28
-26
@@ -1,5 +1,7 @@
|
||||
import "reflect-metadata";
|
||||
import { parseArgs } from "node:util";
|
||||
import denoJson from "../deno.json" with { type: "json" };
|
||||
import { runtimeReadTextFile, serve } from "@umt/runtime";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import type { Config } from "./config.ts";
|
||||
import type { MappingExport } from "./ops.ts";
|
||||
|
||||
@@ -34,7 +36,7 @@ type OneShotRole = (typeof OneShot)[number];
|
||||
const isOneShot = (r: Role): r is OneShotRole => (OneShot as readonly Role[]).includes(r);
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Deno.args,
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
role: { type: "string", default: "all" },
|
||||
out: { type: "string" },
|
||||
@@ -46,32 +48,32 @@ const { values } = parseArgs({
|
||||
const role = values.role as Role;
|
||||
if (!Roles.includes(role)) {
|
||||
console.error(`--role 必须是 ${Roles.join("|")}`);
|
||||
Deno.exit(2);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// fsproxy 执行子进程:必须在加载 db/任务引擎/模块之前分派——每个文件操作起一次,只加载 fs 操作表,启动保持轻量
|
||||
if (role === "fsworker") {
|
||||
const { fsWorkerMain } = await import("@umt/adapters/fsworker");
|
||||
await fsWorkerMain();
|
||||
Deno.exit(0);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 沙箱子进程:同样在加载其余模块前分派,只加载沙箱任务表
|
||||
if (role === "sandbox") {
|
||||
const { sandboxWorkerMain } = await import("./sandbox/worker.ts");
|
||||
await sandboxWorkerMain();
|
||||
Deno.exit(0);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 健康探测:只打本机回环端口,不需要完整配置(HEALTHCHECK 用,镜像里不装 curl)
|
||||
if (role === "probe") {
|
||||
const port = Deno.env.get("PORT") ?? "4380";
|
||||
const port = process.env.PORT ?? "4380";
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, { signal: AbortSignal.timeout(3000) });
|
||||
const body = (await res.json()) as { data?: { status?: string } };
|
||||
Deno.exit(res.ok && body.data?.status === "ok" ? 0 : 1);
|
||||
process.exit(res.ok && body.data?.status === "ok" ? 0 : 1);
|
||||
} catch {
|
||||
Deno.exit(1);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,16 +100,16 @@ try {
|
||||
config = loadConfig();
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
Deno.exit(2);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// 运行期写入统一落在 DATA_DIR;Agent 会话目录经环境变量传给 agent 模块(它只读 AGENT_SESSION_DIR)
|
||||
const paths = dataPaths(config);
|
||||
Deno.env.set("AGENT_SESSION_DIR", paths.sessionDir);
|
||||
process.env.AGENT_SESSION_DIR = paths.sessionDir;
|
||||
|
||||
const logger = createLogger({
|
||||
level: config.LOG_LEVEL,
|
||||
pretty: config.NODE_ENV === "development" && Deno.stdout.isTerminal(),
|
||||
pretty: config.NODE_ENV === "development" && Boolean(process.stdout.isTTY),
|
||||
});
|
||||
const db = createDb({ connectionString: config.DATABASE_URL });
|
||||
|
||||
@@ -121,7 +123,7 @@ if (isOneShot(role)) {
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
Deno.exit(code);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
async function runOneShot(r: OneShotRole): Promise<number> {
|
||||
@@ -155,9 +157,9 @@ async function runOneShot(r: OneShotRole): Promise<number> {
|
||||
}
|
||||
await auditStorageWarnings(db, storage.warnings).catch((err) => logger.warn({ err }, "存储告警写审计失败"));
|
||||
// 验收开关误设提示(验收 07 S-10):只有 UMT_FS_TEST_HOOKS=1 时才真正生效
|
||||
if (Deno.env.get("UMT_FS_TEST_HANG_AFTER_BYTES"))
|
||||
if (process.env.UMT_FS_TEST_HANG_AFTER_BYTES)
|
||||
logger.warn(
|
||||
{ active: Deno.env.get("UMT_FS_TEST_HOOKS") === "1" },
|
||||
{ active: process.env.UMT_FS_TEST_HOOKS === "1" },
|
||||
"设置了 UMT_FS_TEST_HANG_AFTER_BYTES(fsproxy 挂死验收开关);生产环境不要设置,生效时所有数据类文件操作都会卡死到被 SIGKILL",
|
||||
);
|
||||
for (const p of problems) logger.error({ check: p.check }, p.message);
|
||||
@@ -175,11 +177,11 @@ async function runOneShot(r: OneShotRole): Promise<number> {
|
||||
}
|
||||
case "export-mappings": {
|
||||
const out = values.out ?? `${paths.exportsDir}/mappings-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
||||
const data = await exportMappings(db, denoJson.version);
|
||||
const data = await exportMappings(db, packageJson.version);
|
||||
await writeMappingExport(out, data);
|
||||
logger.info({ out, counts: data.counts }, "映射清单已导出");
|
||||
if (!values.compare) return 0;
|
||||
const base = JSON.parse(await Deno.readTextFile(values.compare)) as MappingExport;
|
||||
const base = JSON.parse(await runtimeReadTextFile(values.compare)) as MappingExport;
|
||||
const diff = diffCounts(base.counts, data.counts);
|
||||
if (diff.length) {
|
||||
logger.error({ diff }, "映射清单行数与基准不一致");
|
||||
@@ -206,7 +208,7 @@ try {
|
||||
} catch (err) {
|
||||
logger.error({ err }, "SSE 总线启动失败");
|
||||
await db.destroy();
|
||||
Deno.exit(1);
|
||||
process.exit(1);
|
||||
}
|
||||
const stops: Array<() => Promise<void>> = [];
|
||||
|
||||
@@ -217,7 +219,7 @@ try {
|
||||
} catch (err) {
|
||||
logger.error({ err }, "启动装配失败");
|
||||
await db.destroy();
|
||||
Deno.exit(1);
|
||||
process.exit(1);
|
||||
}
|
||||
const { ctx, outbox, engine } = booted;
|
||||
// 统一元数据缓存:LISTEN 跨进程失效(在线才启用进程内一级缓存)+ 命中统计定期落库
|
||||
@@ -230,13 +232,13 @@ if (role === "api" || role === "all") {
|
||||
bus,
|
||||
db,
|
||||
role,
|
||||
version: denoJson.version,
|
||||
version: packageJson.version,
|
||||
...(config.WEB_ROOT ? { webRoot: config.WEB_ROOT } : {}),
|
||||
modules: SERVER_MODULES,
|
||||
ctx,
|
||||
});
|
||||
const ac = new AbortController();
|
||||
const server = Deno.serve(
|
||||
const server = serve(
|
||||
{
|
||||
port: config.PORT,
|
||||
hostname: config.HOST,
|
||||
@@ -250,7 +252,7 @@ if (role === "api" || role === "all") {
|
||||
app.fetch,
|
||||
);
|
||||
stops.push(async () => {
|
||||
// abort 后 Deno.serve 会断开 SSE 等长连接,finished 在所有连接关闭后 resolve
|
||||
// abort 后 serve 会断开 SSE 等长连接,finished 在所有连接关闭后 resolve
|
||||
ac.abort();
|
||||
await server.finished;
|
||||
});
|
||||
@@ -280,18 +282,18 @@ if (role === "worker" || role === "worker:agent" || role === "all") {
|
||||
}
|
||||
|
||||
let stopping = false;
|
||||
async function shutdown(signal: Deno.Signal) {
|
||||
async function shutdown(signal: NodeJS.Signals) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
logger.info({ signal }, "正在退出");
|
||||
// 兜底:10 秒内没退干净就强退,避免容器 stop 超时被 SIGKILL
|
||||
const force = setTimeout(() => Deno.exit(1), 10_000);
|
||||
const force = setTimeout(() => process.exit(1), 10_000);
|
||||
await Promise.allSettled(stops.map((s) => s()));
|
||||
await engine.stop().catch((err) => logger.error({ err }, "任务引擎关闭失败"));
|
||||
await bus.stop().catch(() => {});
|
||||
await db.destroy();
|
||||
clearTimeout(force);
|
||||
Deno.exit(0);
|
||||
process.exit(0);
|
||||
}
|
||||
Deno.addSignalListener("SIGINT", () => void shutdown("SIGINT"));
|
||||
Deno.addSignalListener("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
import { capabilitySpec, type DownloadStatus } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { normalizeTitle, transition } from "@umt/domain";
|
||||
import { coveringGrant, type PlanBinding, planBinding } from "../agent/authorize.ts";
|
||||
import { previewPolicy, publishRevision } from "./routes/rules.ts";
|
||||
import { coveringGrant, type PlanBinding, planBinding } from "../../authorization/grants.ts";
|
||||
import { evaluateAcquisition, publishedAcquisitionPolicies } from "./services/acquisition.ts";
|
||||
import { type AoDeps, enqueueTracked, ensureTaskRow, markTask } from "./services/common.ts";
|
||||
import { type FrozenPlan, planImport } from "./services/organize.ts";
|
||||
import { previewPolicy, publishRevision } from "./services/policies.ts";
|
||||
import { assignOrganizeIdentity, OrganizeDecisionError } from "./services/takeover.ts";
|
||||
import { errMessage, hashJson, jsonb, toApi, toApiList } from "./util.ts";
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { ModuleApp } from "../../types.ts";
|
||||
import { contextOf, parsedOf } from "../services/acquisition.ts";
|
||||
import type { AoDeps } from "../services/common.ts";
|
||||
import {
|
||||
assertRuleGroupIds,
|
||||
checkAtomPatterns,
|
||||
loadRuleCatalog,
|
||||
normalizeGroupInput,
|
||||
@@ -98,28 +99,15 @@ export async function checkSuppression(trx: Trx, fingerprint: string, source: st
|
||||
/** 被收录版本 / 订阅显式引用的次数 */
|
||||
async function referenceCount(trx: Trx, id: string): Promise<number> {
|
||||
const r = await sql<{ n: number }>`
|
||||
select (select count(*) from acquisition_slot where rule_group_ids @> ${JSON.stringify([id])}::jsonb)
|
||||
select (select count(*) from acquisition_slot where (rule_group_ids || coalesce(upgrade_threshold->'ruleGroupIds', '[]'::jsonb)) @> ${JSON.stringify([id])}::jsonb)
|
||||
+ (select count(*) from subscription where params -> 'ruleGroupIds' @> ${JSON.stringify([id])}::jsonb)
|
||||
+ (select count(*) from policy_revision p where p.kind = 'acquisition' and p.status in ('draft', 'published')
|
||||
and exists (select 1 from jsonb_array_elements(coalesce(p.rules->'defaultSlots', '[]'::jsonb) || coalesce(p.rules->'newTitle'->'slots', '[]'::jsonb)) t
|
||||
where (coalesce(t->'ruleGroupIds', '[]'::jsonb) || coalesce(t->'upgradeThreshold'->'ruleGroupIds', '[]'::jsonb)) @> ${JSON.stringify([id])}::jsonb))
|
||||
as n`.execute(trx);
|
||||
return Number(r.rows[0]?.n ?? 0);
|
||||
}
|
||||
|
||||
async function checkIdsExist(exec: Trx | AoDeps["ctx"]["db"], ids: readonly string[]) {
|
||||
if (!ids.length) return;
|
||||
const rows = await exec
|
||||
.selectFrom("rule_group")
|
||||
.select("id")
|
||||
.where("id", "in", [...ids])
|
||||
.where("deleted_at", "is", null)
|
||||
.execute();
|
||||
const have = new Set(rows.map((r) => r.id));
|
||||
const missing = ids.filter((x) => !have.has(x));
|
||||
if (missing.length) throw new ApiException("bad_request", `规则组不存在:${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
/** 收录版本 / 订阅引用的规则组 id 校验(供 slots 路由复用) */
|
||||
export const assertRuleGroupIds = checkIdsExist;
|
||||
|
||||
const TRACKED_FIELDS = [
|
||||
"name",
|
||||
"description",
|
||||
@@ -204,7 +192,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
const all = [...body.acquisition, ...body.upgrade, ...body.search];
|
||||
await checkIdsExist(trx, all);
|
||||
await assertRuleGroupIds(trx, all);
|
||||
const rows = await trx
|
||||
.selectFrom("rule_group")
|
||||
.select(["id", "kind"])
|
||||
@@ -528,7 +516,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.executeTakeFirst();
|
||||
if (!cur) notFound("规则组");
|
||||
const refs = await referenceCount(trx, cur!.id);
|
||||
if (refs > 0) conflict(`规则组被 ${refs} 个收录版本或订阅引用,先解除引用`);
|
||||
if (refs > 0) conflict(`规则组被 ${refs} 个收录版本、订阅或策略引用,先解除引用`);
|
||||
const row = await trx
|
||||
.updateTable("rule_group")
|
||||
.set({
|
||||
@@ -603,7 +591,9 @@ async function kickReevaluate(d: AoDeps, groupId: string) {
|
||||
const slots = await d.ctx.db
|
||||
.selectFrom("acquisition_slot")
|
||||
.select("id")
|
||||
.where(sql<boolean>`rule_group_ids @> ${JSON.stringify([groupId])}::jsonb`)
|
||||
.where(
|
||||
sql<boolean>`(rule_group_ids || coalesce(upgrade_threshold->'ruleGroupIds', '[]'::jsonb)) @> ${JSON.stringify([groupId])}::jsonb`,
|
||||
)
|
||||
.limit(50)
|
||||
.execute();
|
||||
for (const s of slots)
|
||||
|
||||
@@ -7,24 +7,17 @@ import {
|
||||
type PreferenceTag,
|
||||
} from "@umt/contracts";
|
||||
import { type DB, type PreferenceSuppressionRow, type RuleGroupRow, sql, type Transaction } from "@umt/db";
|
||||
import { evaluateCandidate, type RuleExpr, type RuleLayer, serializeLayers } from "@umt/domain";
|
||||
import { slotSpecFromRow } from "@umt/mapping";
|
||||
import { type RuleExpr, type RuleLayer, serializeLayers } from "@umt/domain";
|
||||
import type { Context } from "hono";
|
||||
import type { AuthEnv } from "../../../http/auth.ts";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import { titleParser } from "../../sources-identity/service/identify-words-cache.ts";
|
||||
import type { ModuleApp } from "../../types.ts";
|
||||
import type { AoDeps, Exec } from "../services/common.ts";
|
||||
import { normalizeGroupInput } from "../services/rules.ts";
|
||||
import { previewPolicy, publishRevision } from "../services/policies.ts";
|
||||
import { assertRuleGroupIds, normalizeGroupInput, upgradeRuleGroupIds } from "../services/rules.ts";
|
||||
import { actorOf, conflict, errMessage, hashJson, jsonb, notFound, toApi, toApiList } from "../util.ts";
|
||||
import { mount, paginate } from "./helpers.ts";
|
||||
import {
|
||||
assertRuleGroupIds,
|
||||
checkSuppression,
|
||||
liftSuppressions,
|
||||
registerRuleGroupRoutes,
|
||||
suppressRuleGroup,
|
||||
} from "./rule-groups.ts";
|
||||
import { checkSuppression, liftSuppressions, registerRuleGroupRoutes, suppressRuleGroup } from "./rule-groups.ts";
|
||||
import { registerSubscriptionRoutes } from "./subscriptions.ts";
|
||||
|
||||
// 规则 API:收录 Slot、策略版本(草案→发布→回滚,发布即持续授权)、偏好标签(规则组别名)与抑制记录;
|
||||
@@ -151,7 +144,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.where("id", "=", body.mediaEntityId)
|
||||
.executeTakeFirst();
|
||||
if (!e) throw new ApiException("bad_request", "mediaEntityId 不存在");
|
||||
await assertRuleGroupIds(db(), body.ruleGroupIds ?? []);
|
||||
await assertRuleGroupIds(db(), [...(body.ruleGroupIds ?? []), ...upgradeRuleGroupIds(body.upgradeThreshold)]);
|
||||
const r = await db()
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
@@ -183,7 +176,8 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
||||
Api.acquisitionSlots.update,
|
||||
async (_c, { params, body }) => {
|
||||
const { expectedRevision, ...b } = body;
|
||||
if (b.ruleGroupIds !== undefined) await assertRuleGroupIds(db(), b.ruleGroupIds);
|
||||
if (b.ruleGroupIds !== undefined || b.upgradeThreshold !== undefined)
|
||||
await assertRuleGroupIds(db(), [...(b.ruleGroupIds ?? []), ...upgradeRuleGroupIds(b.upgradeThreshold)]);
|
||||
const r = await db()
|
||||
.updateTable("acquisition_slot")
|
||||
.set({
|
||||
@@ -745,119 +739,3 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
|
||||
// 规则组 /rules/*:含 /rules/{id},必须最后注册(静态的 /rules/policies、/rules/suppressions 在前)
|
||||
registerRuleGroupRoutes(app, d);
|
||||
}
|
||||
|
||||
/** 发布:旧 published → superseded/rolled_back;吊销旧授权;新版本生成持续授权 automation_grant */
|
||||
export async function publishRevision(
|
||||
trx: Transaction<DB>,
|
||||
id: string,
|
||||
policyKey: string,
|
||||
by: string,
|
||||
oldStatus: "superseded" | "rolled_back",
|
||||
) {
|
||||
const old = await trx
|
||||
.selectFrom("policy_revision")
|
||||
.select(["id"])
|
||||
.where("policy_key", "=", policyKey)
|
||||
.where("status", "=", "published")
|
||||
.execute();
|
||||
const now = new Date();
|
||||
for (const o of old as { id: string }[]) {
|
||||
await trx.updateTable("policy_revision").set({ status: oldStatus }).where("id", "=", o.id).execute();
|
||||
await trx
|
||||
.updateTable("automation_grant")
|
||||
.set({ status: "revoked", revoked_at: now, revoked_by: by })
|
||||
.where("policy_revision_id", "=", o.id)
|
||||
.where("status", "=", "active")
|
||||
.execute();
|
||||
}
|
||||
const row = await trx
|
||||
.updateTable("policy_revision")
|
||||
.set({ status: "published", published_at: now, published_by: by })
|
||||
.where("id", "=", id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
const rules = (row.rules ?? {}) as { budget?: unknown };
|
||||
await trx
|
||||
.insertInto("automation_grant")
|
||||
.values({
|
||||
action: `policy.${row.kind}`,
|
||||
policy_revision_id: row.id,
|
||||
scope: jsonb(row.scope),
|
||||
budget: jsonb(rules.budget ?? {}),
|
||||
granted_by: by,
|
||||
reason: `发布策略 ${policyKey} r${row.revision}`,
|
||||
})
|
||||
.execute();
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 预览:用规则里的 Slot 模板评估最近已识别的发布(确定性,不产生副作用) */
|
||||
export async function previewPolicy(d: AoDeps, rules: unknown) {
|
||||
const r = AcquisitionPolicyRules.safeParse(rules);
|
||||
const templates = r.success ? [...(r.data.defaultSlots ?? []), ...(r.data.newTitle?.slots ?? [])] : [];
|
||||
const rels = await d.ctx.db
|
||||
.selectFrom("source_release")
|
||||
.select(["id", "title", "subtitle", "size_bytes"])
|
||||
.where("resolution_status", "=", "resolved")
|
||||
.orderBy("first_seen_at", "desc")
|
||||
.limit(200)
|
||||
.execute();
|
||||
let matched = 0;
|
||||
let excluded = 0;
|
||||
const samples: {
|
||||
sourceReleaseId: string;
|
||||
title: string;
|
||||
sizeBytes: number | null;
|
||||
template: string;
|
||||
decision: string;
|
||||
reasons: string[];
|
||||
}[] = [];
|
||||
const parse = await titleParser(d.ctx.db);
|
||||
for (const rel of rels) {
|
||||
const parsed = {
|
||||
...parse(rel.title, rel.subtitle),
|
||||
...(rel.size_bytes !== null ? { sizeBytes: rel.size_bytes } : {}),
|
||||
};
|
||||
for (const t of templates) {
|
||||
const spec = slotSpecFromRow({
|
||||
id: "preview",
|
||||
name: t.name,
|
||||
hardConditions: t.hardConditions ?? {},
|
||||
softPreferences: t.softPreferences ?? {},
|
||||
upgradeThreshold: t.upgradeThreshold ?? {},
|
||||
completion: t.completion ?? {},
|
||||
fallback: t.fallback ?? {},
|
||||
});
|
||||
const ev = evaluateCandidate(spec, parsed, null);
|
||||
if (ev.decision === "accept") matched++;
|
||||
else excluded++;
|
||||
if (samples.length < 20) {
|
||||
const reasons =
|
||||
ev.decision === "reject"
|
||||
? ev.violations.map((v) => `${v.field}:${v.code}`)
|
||||
: ev.decision === "unknown_field"
|
||||
? ev.missing.map((m) => `missing:${m}`)
|
||||
: "reasons" in ev
|
||||
? (ev.reasons ?? [])
|
||||
: [];
|
||||
samples.push({
|
||||
sourceReleaseId: rel.id,
|
||||
title: rel.title,
|
||||
sizeBytes: rel.size_bytes,
|
||||
template: t.name,
|
||||
decision: ev.decision,
|
||||
reasons,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
evaluatedAt: new Date().toISOString(),
|
||||
sampleSize: rels.length,
|
||||
templates: templates.length,
|
||||
matched,
|
||||
excluded,
|
||||
samples,
|
||||
valid: r.success,
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { ApiException } from "../../../http/errors.ts";
|
||||
import { emitMediaWatched } from "../../sources-identity/service/lifecycle-events.ts";
|
||||
import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts";
|
||||
import type { ModuleApp } from "../../types.ts";
|
||||
import { ensureSubscriptionSlot } from "../services/acquisition.ts";
|
||||
import { collectionContext, ensureSubscriptionSlot } from "../services/collection-strategy.ts";
|
||||
import type { AoDeps, Exec } from "../services/common.ts";
|
||||
import {
|
||||
buildSubscriptionModels,
|
||||
@@ -352,6 +352,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
// pending:TMDB 未就绪(未建档,或剧集没有已接受的 TMDB 身份 → 总集数无来源):照常搜索/评估,不能完成
|
||||
const ready = await tmdbReady(db(), body.mediaEntityId ?? null, body.mediaKind);
|
||||
const now = d.ports.now();
|
||||
const context = await collectionContext(d, body.mediaEntityId ?? null);
|
||||
const row = await db()
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
@@ -376,7 +377,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
// 订阅总有自己的收录版本:策略模板,没有就建一个默认版本(质量预设随后由 applyDerived 写入)
|
||||
await ensureSubscriptionSlot(trx, r);
|
||||
await ensureSubscriptionSlot(trx, r, context);
|
||||
await applyDerived(trx, r, norm, now);
|
||||
// 生命周期广播(ADR-006):订阅已建;关联作品 = 作品进入关注
|
||||
await emitSubscriptionEvent(d, trx, "subscription.created", r);
|
||||
@@ -419,6 +420,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
seasonUnitId: cur.season_unit_id,
|
||||
excludeId: cur.id,
|
||||
});
|
||||
const context = await collectionContext(d, cur.media_entity_id);
|
||||
const r = await db()
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
@@ -438,7 +440,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
// 旧订阅没有自己的收录版本:编辑时补建(质量预设 / 升级策略才有地方写)
|
||||
if (r.status !== "cancelled") await ensureSubscriptionSlot(trx, r);
|
||||
if (r.status !== "cancelled") await ensureSubscriptionSlot(trx, r, context);
|
||||
if (norm) await applyDerived(trx, r, norm, d.ports.now(), subParams(cur));
|
||||
else if (body.status !== undefined) await syncPrivateRuleGroup(trx, r, d.ports.now());
|
||||
await emitSubscriptionChange(trx, cur, r, Object.keys(body));
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
type AcquisitionPolicyRules,
|
||||
type AcquisitionPriority,
|
||||
type CandidateEvaluation,
|
||||
lifecycleKey,
|
||||
type SlotTemplate,
|
||||
} from "@umt/contracts";
|
||||
import { type AcquisitionPriority, type CandidateEvaluation, lifecycleKey } from "@umt/contracts";
|
||||
import type { AcquisitionSlotRow, MediaEntityRow, SourceReleaseRow, SubscriptionRow } from "@umt/db";
|
||||
import { sql } from "@umt/db";
|
||||
import {
|
||||
@@ -24,13 +18,22 @@ import {
|
||||
type SlotEvaluation,
|
||||
type SlotSpec,
|
||||
type SlotState,
|
||||
seasonReleaseKey,
|
||||
seasonWildcard,
|
||||
selectAcquisitionPolicy,
|
||||
transition,
|
||||
unitsOverlap,
|
||||
upgradeSpec,
|
||||
} from "@umt/domain";
|
||||
import { toContractSlotEvaluation } from "@umt/mapping";
|
||||
import { hasHook, rewriteHook } from "../../../hooks/index.ts";
|
||||
import { contextOf, episodeResolutionOf, parsedOf, unitKeysOf } from "../../sources-identity/service/release-facts.ts";
|
||||
import { hashJson, jsonb, startOfDay } from "../util.ts";
|
||||
import {
|
||||
collectionContext,
|
||||
createSlotsFromTemplates,
|
||||
ensureSubscriptionSlot,
|
||||
publishedAcquisitionPolicies,
|
||||
} from "./collection-strategy.ts";
|
||||
import {
|
||||
type AoDeps,
|
||||
automationBlock,
|
||||
@@ -43,15 +46,16 @@ import {
|
||||
loadInstances,
|
||||
pickInstance,
|
||||
recordCandidate,
|
||||
upsertDecision,
|
||||
} from "./common.ts";
|
||||
import { autoFillCheck, type ForceFill } from "./evaluate-autofill.ts";
|
||||
import { emitSubscriptionEvent } from "./lifecycle-events.ts";
|
||||
import { contextOf, episodeResolutionOf, parsedOf, unitKeysOf } from "./release-facts.ts";
|
||||
import { entityOriginalLanguage, loadRuleCatalog, type RuleCatalog } from "./rules.ts";
|
||||
import {
|
||||
clearDeferral,
|
||||
clearReleaseDeferrals,
|
||||
clearSubscriptionDeferrals,
|
||||
DEFAULT_SLOT_REEVALUATE_MS,
|
||||
gateRetryDelayMs,
|
||||
manualInflightPlans,
|
||||
rangeOf,
|
||||
@@ -67,10 +71,18 @@ import {
|
||||
subSeasonOf,
|
||||
TRACKING_SUB_STATUSES,
|
||||
} from "./subscriptions.ts";
|
||||
import { applySlotPreset } from "./subscriptions-params.ts";
|
||||
|
||||
export {
|
||||
createSlotsFromTemplates,
|
||||
ensureSubscriptionSlot,
|
||||
publishedAcquisitionPolicies,
|
||||
} from "./collection-strategy.ts";
|
||||
|
||||
import { missingReleaseFields } from "../../sources-identity/service/release-facts-admin.ts";
|
||||
import { seasonReleaseTarget } from "./season-release.ts";
|
||||
import { decideVolume, loadVolumes } from "./volumes.ts";
|
||||
|
||||
export { contextOf, parsedOf, unitKeysOf } from "./release-facts.ts";
|
||||
export { contextOf, parsedOf, unitKeysOf } from "../../sources-identity/service/release-facts.ts";
|
||||
|
||||
// evaluateAcquisition:release.resolved → 匹配订阅/Slot → 模式 → 确定性评估 → 预算/缓存卷 → 冻结计划并预留。
|
||||
// 拒绝与被挡住的原因都写成候选评估记录(artifact),供作品详情"为什么选/不选"。
|
||||
@@ -172,127 +184,30 @@ const firstReleaseDate = (e: MediaEntityRow): string | undefined => {
|
||||
return e.year ? `${e.year}-01-01` : undefined;
|
||||
};
|
||||
|
||||
/** 已发布的收录策略(kind=acquisition) */
|
||||
export async function publishedAcquisitionPolicies(exec: Exec) {
|
||||
const rows = await exec
|
||||
.selectFrom("policy_revision")
|
||||
.selectAll()
|
||||
.where("kind", "=", "acquisition")
|
||||
.where("status", "=", "published")
|
||||
.execute();
|
||||
return rows.map((r) => ({
|
||||
row: r,
|
||||
rules: (r.rules ?? {}) as AcquisitionPolicyRules,
|
||||
scope: (r.scope ?? {}) as { mediaKinds?: string[] },
|
||||
}));
|
||||
}
|
||||
|
||||
const subKindOf = (k: MediaEntityRow["kind"]): "movie" | "series" | "variety" =>
|
||||
k === "movie" ? "movie" : k === "variety" ? "variety" : "series";
|
||||
|
||||
export async function createSlotsFromTemplates(
|
||||
exec: Exec,
|
||||
input: {
|
||||
entityId: string;
|
||||
subscriptionId: string | null;
|
||||
policyRevisionId: string | null;
|
||||
templates: readonly SlotTemplate[];
|
||||
},
|
||||
): Promise<number> {
|
||||
// 收录版本名在作品内唯一(media_entity_id, content_unit_id, name):旧订阅留下的同名版本不挡新订阅,改名加序号
|
||||
const taken = new Set(
|
||||
(
|
||||
await exec
|
||||
.selectFrom("acquisition_slot")
|
||||
.select("name")
|
||||
.where("media_entity_id", "=", input.entityId)
|
||||
.where("content_unit_id", "is", null)
|
||||
.execute()
|
||||
).map((r) => r.name),
|
||||
);
|
||||
let n = 0;
|
||||
for (const [i, t] of input.templates.entries()) {
|
||||
let name = t.name;
|
||||
for (let k = 2; taken.has(name) && k < 1000; k++) name = `${t.name} ${k}`;
|
||||
taken.add(name);
|
||||
const row = await exec
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
// 同一事务建的收录版本 created_at 相同:按模板顺序给 position,第一个为主版本
|
||||
position: i,
|
||||
media_entity_id: input.entityId,
|
||||
subscription_id: input.subscriptionId,
|
||||
policy_revision_id: input.policyRevisionId,
|
||||
name,
|
||||
hard_conditions: jsonb(t.hardConditions),
|
||||
soft_preferences: jsonb(t.softPreferences),
|
||||
fallback: jsonb(t.fallback),
|
||||
upgrade_threshold: jsonb(t.upgradeThreshold),
|
||||
completion: jsonb(t.completion),
|
||||
rule_group_ids: JSON.stringify(t.ruleGroupIds ?? []),
|
||||
})
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.returning("id")
|
||||
.executeTakeFirst();
|
||||
if (row) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 追踪中的订阅里没有自己收录版本的,逐个补建;返回是否建了 */
|
||||
async function backfillSubscriptionSlots(
|
||||
db: AoDeps["ctx"]["db"],
|
||||
d: AoDeps,
|
||||
subs: readonly SubscriptionRow[],
|
||||
slots: readonly Pick<AcquisitionSlotRow, "subscription_id">[],
|
||||
): Promise<boolean> {
|
||||
let made = 0;
|
||||
for (const s of subs)
|
||||
if (s.media_entity_id && !slots.some((x) => x.subscription_id === s.id)) made += await ensureSlotLocked(db, s);
|
||||
if (s.media_entity_id && !slots.some((x) => x.subscription_id === s.id)) made += await ensureSlotLocked(d, s);
|
||||
return made > 0;
|
||||
}
|
||||
|
||||
/** 评估路径上补建:锁订阅行,免得并发的两次评估各建一个 */
|
||||
function ensureSlotLocked(db: AoDeps["ctx"]["db"], sub: SubscriptionRow): Promise<number> {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
async function ensureSlotLocked(d: AoDeps, sub: SubscriptionRow): Promise<number> {
|
||||
const context = await collectionContext(d, sub.media_entity_id);
|
||||
return d.ctx.db.transaction().execute(async (trx) => {
|
||||
await trx.selectFrom("subscription").select("id").where("id", "=", sub.id).forUpdate().execute();
|
||||
return ensureSubscriptionSlot(trx, sub);
|
||||
return ensureSubscriptionSlot(trx, sub, context);
|
||||
});
|
||||
}
|
||||
|
||||
/** 订阅没有收录策略模板时的隐式缺省收录版本:无硬条件,表单的质量预设 / 包含排除随后由订阅参数写入 */
|
||||
export const DEFAULT_SLOT_TEMPLATE: SlotTemplate = { name: "默认版本" };
|
||||
|
||||
/**
|
||||
* 订阅总有属于自己的收录版本(H1):没有时按收录策略模板(指定的或第一个带 defaultSlots 的已发布策略)建,
|
||||
* 没有模板就建一个隐式「默认版本」,再把订阅表单的质量预设写进去。作品已有别的订阅(旧季、已取消)的
|
||||
* 收录版本不算——评估时订阅只用自己的收录版本。返回新建个数(已有则 0)。
|
||||
*/
|
||||
export async function ensureSubscriptionSlot(
|
||||
exec: Exec,
|
||||
sub: Pick<SubscriptionRow, "id" | "media_entity_id" | "params" | "policy_revision_id">,
|
||||
): Promise<number> {
|
||||
if (!sub.media_entity_id) return 0;
|
||||
const own = await exec
|
||||
.selectFrom("acquisition_slot")
|
||||
.select("id")
|
||||
.where("subscription_id", "=", sub.id)
|
||||
.executeTakeFirst();
|
||||
if (own) return 0;
|
||||
const pols = await publishedAcquisitionPolicies(exec);
|
||||
const pol =
|
||||
(sub.policy_revision_id ? pols.find((p) => p.row.id === sub.policy_revision_id) : undefined) ??
|
||||
pols.find((p) => p.rules.defaultSlots?.length);
|
||||
const fromPolicy = !!pol?.rules.defaultSlots?.length;
|
||||
const n = await createSlotsFromTemplates(exec, {
|
||||
entityId: sub.media_entity_id,
|
||||
subscriptionId: sub.id,
|
||||
policyRevisionId: fromPolicy ? pol!.row.id : null,
|
||||
templates: fromPolicy ? pol!.rules.defaultSlots! : [DEFAULT_SLOT_TEMPLATE],
|
||||
});
|
||||
if (n > 0) await applySlotPreset(exec, { subscriptionId: sub.id, prev: {}, next: subParams(sub) });
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预算用量。并发 = 在跑的下载任务(不含 stalled:无进展任务不占名额,否则卡住几个就把追更全挡住)
|
||||
* + 已预留但还没建下载任务的计划(刚预留、等补投;防止一轮评估连续预留超过并发上限)。
|
||||
@@ -392,7 +307,7 @@ async function reevaluate(c: Ctx, input: EvaluateInput): Promise<EvaluateOutcome
|
||||
sub = sid ? await db.selectFrom("subscription").selectAll().where("id", "=", sid).executeTakeFirst() : undefined;
|
||||
} else if (input.subscriptionId) {
|
||||
sub = await db.selectFrom("subscription").selectAll().where("id", "=", input.subscriptionId).executeTakeFirst();
|
||||
if (sub && (TRACKING_SUB_STATUSES as readonly string[]).includes(sub.status)) await ensureSlotLocked(db, sub);
|
||||
if (sub && (TRACKING_SUB_STATUSES as readonly string[]).includes(sub.status)) await ensureSlotLocked(c.d, sub);
|
||||
if (sub)
|
||||
focus = await db
|
||||
.selectFrom("acquisition_slot")
|
||||
@@ -439,13 +354,8 @@ async function reevaluate(c: Ctx, input: EvaluateInput): Promise<EvaluateOutcome
|
||||
rels.filter((x) => !seen.has(x.id)),
|
||||
);
|
||||
const ranked = await rankReleases(c, entity, sub, focus[0], [...deferred, ...fresh]);
|
||||
const ordered = await selectResources(c, ranked, {
|
||||
mediaEntityId: entity.id,
|
||||
subscriptionId: sub?.id ?? null,
|
||||
slotId: input.acquisitionSlotId ?? null,
|
||||
});
|
||||
const scope = input.acquisitionSlotId ? { slotId: input.acquisitionSlotId } : sub ? { subscriptionId: sub.id } : {};
|
||||
for (const r of ordered) await evaluateRelease(c, r, [], scope);
|
||||
for (const r of ranked) await evaluateRelease(c, r, [], scope);
|
||||
return c.out;
|
||||
}
|
||||
|
||||
@@ -513,33 +423,6 @@ async function rankReleases(
|
||||
})).map((x) => byParsed.get(x.candidate)!);
|
||||
}
|
||||
|
||||
/**
|
||||
* 链式钩子 resource.selection(ADR-006):在择优排序之后调整顺序或过滤。钩子只能重排 / 删减,
|
||||
* 返回里不认识的 ID 忽略、重复的只取第一次;没有钩子时原样返回。
|
||||
*/
|
||||
export async function selectResources(
|
||||
c: Ctx,
|
||||
rels: SourceReleaseRow[],
|
||||
context: { mediaEntityId: string; subscriptionId: string | null; slotId: string | null },
|
||||
): Promise<SourceReleaseRow[]> {
|
||||
if (!hasHook(c.d.ctx, "resource.selection") || rels.length === 0) return rels;
|
||||
const byId = new Map(rels.map((r) => [r.id, r]));
|
||||
const picked = await rewriteHook(
|
||||
c.d.ctx,
|
||||
"resource.selection",
|
||||
rels.map((r) => ({ sourceReleaseId: r.id, title: r.title, parsed: parsedOf(r) })),
|
||||
context,
|
||||
);
|
||||
const out: SourceReleaseRow[] = [];
|
||||
for (const p of picked) {
|
||||
const r = byId.get(p.sourceReleaseId);
|
||||
if (!r) continue;
|
||||
byId.delete(p.sourceReleaseId);
|
||||
out.push(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 评估范围:只评某个收录版本,或只评某个订阅的收录版本;缺省评作品的全部收录版本 */
|
||||
interface EvalScope {
|
||||
slotId?: string | undefined;
|
||||
@@ -596,10 +479,14 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
|
||||
.where("status", "=", "active")
|
||||
.executeTakeFirst();
|
||||
if (hasAsset) return skip("not_tracked");
|
||||
const pol = (await publishedAcquisitionPolicies(db)).find(
|
||||
(p) =>
|
||||
p.rules.newTitle?.enabled &&
|
||||
(!p.rules.newTitle.mediaKinds?.length || p.rules.newTitle.mediaKinds.includes(subKindOf(entity.kind))),
|
||||
const context = await collectionContext(d, entity.id);
|
||||
const pol = selectAcquisitionPolicy(
|
||||
(await publishedAcquisitionPolicies(db)).filter(
|
||||
(p) =>
|
||||
p.rules.newTitle?.enabled &&
|
||||
(!p.rules.newTitle.mediaKinds?.length || p.rules.newTitle.mediaKinds.includes(subKindOf(entity.kind))),
|
||||
),
|
||||
{ mediaKind: subKindOf(entity.kind), category: context?.category ?? null },
|
||||
);
|
||||
if (!pol?.rules.newTitle) return skip("not_tracked");
|
||||
const m = determineMode({
|
||||
@@ -687,7 +574,7 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
|
||||
subs.filter((s) => (TRACKING_SUB_STATUSES as readonly string[]).includes(s.status)).map((s) => [s.id, s]),
|
||||
);
|
||||
// 一次性补建:旧版本建的订阅可能没有自己的收录版本(H1),首次评估时补上
|
||||
if (await backfillSubscriptionSlots(db, [...activeSubs.values()], slots))
|
||||
if (await backfillSubscriptionSlots(d, [...activeSubs.values()], slots))
|
||||
slots = await db
|
||||
.selectFrom("acquisition_slot")
|
||||
.selectAll()
|
||||
@@ -849,8 +736,8 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
|
||||
if (isMovie) {
|
||||
if (inflight.length > 0 || BUSY.includes(slot.status)) return defer("slot_busy", []);
|
||||
const current = await currentFacts(db, slot.current_asset_id);
|
||||
if (current && slot.current_asset_id && rulesLaddered(spec)) {
|
||||
const pri = await rulePriOfAsset(c, spec, slot.current_asset_id, ctx.originalLanguage);
|
||||
if (current && slot.current_asset_id && rulesLaddered(upgradeSpec(spec))) {
|
||||
const pri = await rulePriOfAsset(c, upgradeSpec(spec), slot.current_asset_id, ctx.originalLanguage);
|
||||
if (pri !== undefined) current.pri = pri;
|
||||
}
|
||||
ev = evaluateCandidate(spec, parsed, slotSatisfied ? current : null, ctx);
|
||||
@@ -902,11 +789,25 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
|
||||
return defer("units_inflight", reservedHit);
|
||||
} else {
|
||||
// 都已覆盖:分集洗版,只取能提升的单元(对照 MP get_best_version_interested_episodes)
|
||||
if (spec.upgrade?.consistency === "season_release") {
|
||||
const washing = upgradeSpec(spec);
|
||||
const pri = effectiveLadder(washing, parsed, ctx, ruleChainOf(washing, parsed, ctx)).pri;
|
||||
ctx.seasonReleaseTarget = await seasonReleaseTarget(
|
||||
db,
|
||||
slot,
|
||||
p.releaseSeason,
|
||||
washing,
|
||||
{ ...parsed, pri },
|
||||
ctx.originalLanguage,
|
||||
);
|
||||
}
|
||||
const unitCurrent = await unitQuality(
|
||||
db,
|
||||
slot,
|
||||
releaseKeys,
|
||||
rulesLaddered(spec) ? (assetId) => rulePriOfAsset(c, spec, assetId, ctx.originalLanguage) : undefined,
|
||||
rulesLaddered(upgradeSpec(spec))
|
||||
? (assetId) => rulePriOfAsset(c, upgradeSpec(spec), assetId, ctx.originalLanguage)
|
||||
: undefined,
|
||||
);
|
||||
const withCurrent = releaseKeys.filter((k) => unitCurrent[k]);
|
||||
const firstCurrent = withCurrent.length
|
||||
@@ -928,8 +829,30 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
|
||||
}
|
||||
}
|
||||
const flat = toContractSlotEvaluation(ev);
|
||||
const evalExtra = { pri: flat.pri, trace: flat.trace };
|
||||
const evalExtra = {
|
||||
pri: flat.pri,
|
||||
trace: flat.trace,
|
||||
quality: { ...parsed, pri: flat.pri },
|
||||
...(ctx.seasonReleaseTarget
|
||||
? { seasonRelease: seasonReleaseKey(ctx.seasonReleaseTarget), season: p.releaseSeason }
|
||||
: {}),
|
||||
};
|
||||
if (!flat.acceptable) {
|
||||
if (!c.draft && ev.decision === "unknown_field") {
|
||||
const missing = missingReleaseFields(rel, ev.missing).filter((f) =>
|
||||
["resolution", "codec", "source", "group", "size", "subtitles", "seeders", "promotion"].includes(f),
|
||||
);
|
||||
if (missing.length)
|
||||
await upsertDecision(d, db, {
|
||||
kind: "other",
|
||||
subjectType: "source_release",
|
||||
subjectId: rel.id,
|
||||
dedupeKey: `release_facts:${rel.id}`,
|
||||
title: `资源属性缺失:${rel.title}`,
|
||||
summary: "查证缺失属性后重新按收录规则评估",
|
||||
evidence: { sourceReleaseId: rel.id, mediaEntityId: p.entity.id, slotId: slot.id, missing },
|
||||
});
|
||||
}
|
||||
await note(c, {
|
||||
...base,
|
||||
decision: ev.decision as "reject" | "unknown_field",
|
||||
@@ -1032,7 +955,8 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
|
||||
savePath: gate.savePath,
|
||||
unitKeys: planUnits,
|
||||
multiInflight: !isMovie,
|
||||
...(fill.action === "fill" ? { priority: fill.priority, evaluationExtra: fill.evaluation } : {}),
|
||||
...(fill.action === "fill" ? { priority: fill.priority } : {}),
|
||||
evaluationExtra: { ...evalExtra, ...(fill.action === "fill" ? fill.evaluation : {}) },
|
||||
});
|
||||
if (r.conflict) return defer("units_inflight", r.conflict, mode);
|
||||
if (r.retryAt) {
|
||||
@@ -1305,10 +1229,12 @@ async function reserve(
|
||||
unitKeys: p.unitKeys,
|
||||
};
|
||||
// 升级 = 替换已有资产;电影 Slot 缺口(无资产)的 mode 虽记 upgrade,用途仍是补缺
|
||||
const purpose = p.mode === "upgrade" && p.slot.current_asset_id ? ("upgrade" as const) : ("fill" as const);
|
||||
const purpose = p.ev.decision === "upgrade" ? ("upgrade" as const) : ("fill" as const);
|
||||
const evaluation = jsonb({
|
||||
...toContractSlotEvaluation(p.ev),
|
||||
mode: p.mode,
|
||||
unitKeys: p.unitKeys,
|
||||
...(purpose === "upgrade" ? { automaticReplacement: true } : {}),
|
||||
instanceId: p.instanceId,
|
||||
volumeKey: p.volumeKey,
|
||||
savePath: p.savePath,
|
||||
@@ -1373,6 +1299,23 @@ async function reserve(
|
||||
.executeTakeFirst();
|
||||
if (!slot) return { planId: null };
|
||||
const busy = BUSY.includes(slot.status);
|
||||
if (p.evaluationExtra?.seasonRelease && typeof p.evaluationExtra.season === "number") {
|
||||
const spec = upgradeSpec(
|
||||
slotSpecWithRules(
|
||||
await loadRuleCatalog(trx),
|
||||
slot,
|
||||
p.sub,
|
||||
{ id: slot.media_entity_id, kind: p.sub?.media_kind ?? "series" },
|
||||
p.rel.site_id,
|
||||
),
|
||||
);
|
||||
const target = await seasonReleaseTarget(trx, slot, p.evaluationExtra.season, spec, {
|
||||
...parsedOf(p.rel),
|
||||
pri: "pri" in p.ev ? p.ev.pri : undefined,
|
||||
});
|
||||
if (!target || seasonReleaseKey(target) !== p.evaluationExtra.seasonRelease)
|
||||
return { planId: null, retryAt: new Date(now.getTime() + DEFAULT_SLOT_REEVALUATE_MS) };
|
||||
}
|
||||
if (p.multiInflight) {
|
||||
// Slot×单元预留:在 Slot 行锁内复核与其它在途计划的单元不相交
|
||||
const others = await slotInflightPlans(trx, slot.id);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
type AcquisitionPolicyRules,
|
||||
AcquisitionPolicyScope,
|
||||
type SlotTemplate,
|
||||
type SubscriptionParams,
|
||||
} from "@umt/contracts";
|
||||
import type { SubscriptionRow } from "@umt/db";
|
||||
import { selectAcquisitionPolicy } from "@umt/domain";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import { jsonb } from "../util.ts";
|
||||
import { classifyEntity } from "./classification.ts";
|
||||
import type { AoDeps, Exec } from "./common.ts";
|
||||
import { applySlotPreset } from "./subscriptions-params.ts";
|
||||
|
||||
export async function collectionContext(d: AoDeps, entityId: string | null) {
|
||||
if (!entityId) return undefined;
|
||||
const entity = await d.ctx.db.selectFrom("media_entity").selectAll().where("id", "=", entityId).executeTakeFirst();
|
||||
if (!entity) return undefined;
|
||||
return {
|
||||
mediaKind:
|
||||
entity.kind === "movie"
|
||||
? ("movie" as const)
|
||||
: entity.kind === "variety"
|
||||
? ("variety" as const)
|
||||
: ("series" as const),
|
||||
category: (await classifyEntity(d, entity)).category,
|
||||
};
|
||||
}
|
||||
|
||||
/** 已发布的收录策略(kind=acquisition) */
|
||||
export async function publishedAcquisitionPolicies(exec: Exec) {
|
||||
const rows = await exec
|
||||
.selectFrom("policy_revision")
|
||||
.selectAll()
|
||||
.where("kind", "=", "acquisition")
|
||||
.where("status", "=", "published")
|
||||
.orderBy("published_at", "desc")
|
||||
.orderBy("policy_key")
|
||||
.execute();
|
||||
return rows.map((r) => ({
|
||||
row: r,
|
||||
rules: (r.rules ?? {}) as AcquisitionPolicyRules,
|
||||
id: r.id,
|
||||
scope: AcquisitionPolicyScope.parse(r.scope ?? {}),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createSlotsFromTemplates(
|
||||
exec: Exec,
|
||||
input: {
|
||||
entityId: string;
|
||||
subscriptionId: string | null;
|
||||
policyRevisionId: string | null;
|
||||
templates: readonly SlotTemplate[];
|
||||
},
|
||||
): Promise<number> {
|
||||
// 收录版本名在作品内唯一(media_entity_id, content_unit_id, name):旧订阅留下的同名版本不挡新订阅,改名加序号
|
||||
const taken = new Set(
|
||||
(
|
||||
await exec
|
||||
.selectFrom("acquisition_slot")
|
||||
.select("name")
|
||||
.where("media_entity_id", "=", input.entityId)
|
||||
.where("content_unit_id", "is", null)
|
||||
.execute()
|
||||
).map((r) => r.name),
|
||||
);
|
||||
let n = 0;
|
||||
for (const [i, t] of input.templates.entries()) {
|
||||
let name = t.name;
|
||||
for (let k = 2; taken.has(name) && k < 1000; k++) name = `${t.name} ${k}`;
|
||||
taken.add(name);
|
||||
const row = await exec
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
// 同一事务建的收录版本 created_at 相同:按模板顺序给 position,第一个为主版本
|
||||
position: i,
|
||||
media_entity_id: input.entityId,
|
||||
subscription_id: input.subscriptionId,
|
||||
policy_revision_id: input.policyRevisionId,
|
||||
name,
|
||||
hard_conditions: jsonb(t.hardConditions),
|
||||
soft_preferences: jsonb(t.softPreferences),
|
||||
fallback: jsonb(t.fallback),
|
||||
upgrade_threshold: jsonb(t.upgradeThreshold),
|
||||
completion: jsonb(t.completion),
|
||||
rule_group_ids: JSON.stringify(t.ruleGroupIds ?? []),
|
||||
})
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.returning("id")
|
||||
.executeTakeFirst();
|
||||
if (row) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 订阅没有收录策略模板时的隐式缺省收录版本:无硬条件,表单的质量预设 / 包含排除随后由订阅参数写入 */
|
||||
export const DEFAULT_SLOT_TEMPLATE: SlotTemplate = { name: "默认版本" };
|
||||
|
||||
/**
|
||||
* 订阅总有属于自己的收录版本(H1):没有时按收录策略模板(显式指定、分类默认、类型默认或全局默认的已发布策略)建,
|
||||
* 没有模板就建一个隐式「默认版本」,再把订阅表单的质量预设写进去。作品已有别的订阅(旧季、已取消)的
|
||||
* 收录版本不算——评估时订阅只用自己的收录版本。返回新建个数(已有则 0)。
|
||||
*/
|
||||
export async function ensureSubscriptionSlot(
|
||||
exec: Exec,
|
||||
sub: Pick<SubscriptionRow, "id" | "media_entity_id" | "params" | "policy_revision_id">,
|
||||
context?: { mediaKind: "movie" | "series" | "variety"; category: string | null },
|
||||
): Promise<number> {
|
||||
if (!sub.media_entity_id) return 0;
|
||||
const own = await exec
|
||||
.selectFrom("acquisition_slot")
|
||||
.select("id")
|
||||
.where("subscription_id", "=", sub.id)
|
||||
.executeTakeFirst();
|
||||
if (own) return 0;
|
||||
const pols = await publishedAcquisitionPolicies(exec);
|
||||
const entity = await exec
|
||||
.selectFrom("media_entity")
|
||||
.select("kind")
|
||||
.where("id", "=", sub.media_entity_id)
|
||||
.executeTakeFirstOrThrow();
|
||||
const pol = selectAcquisitionPolicy(
|
||||
pols.filter((p) => p.rules.defaultSlots?.length),
|
||||
{
|
||||
explicitId: sub.policy_revision_id,
|
||||
mediaKind:
|
||||
context?.mediaKind ?? (entity.kind === "movie" ? "movie" : entity.kind === "variety" ? "variety" : "series"),
|
||||
category: context?.category ?? null,
|
||||
},
|
||||
);
|
||||
if (sub.policy_revision_id && !pol)
|
||||
throw new ApiException("precondition_failed", "所选收录策略尚未发布或没有收录版本");
|
||||
const fromPolicy = !!pol?.rules.defaultSlots?.length;
|
||||
const n = await createSlotsFromTemplates(exec, {
|
||||
entityId: sub.media_entity_id,
|
||||
subscriptionId: sub.id,
|
||||
policyRevisionId: fromPolicy ? pol!.row.id : null,
|
||||
templates: fromPolicy ? pol!.rules.defaultSlots! : [DEFAULT_SLOT_TEMPLATE],
|
||||
});
|
||||
if (n > 0)
|
||||
await applySlotPreset(exec, { subscriptionId: sub.id, prev: {}, next: (sub.params ?? {}) as SubscriptionParams });
|
||||
return n;
|
||||
}
|
||||
@@ -37,7 +37,11 @@ export async function upsertDecision(d: AoDeps, exec: Exec, input: DecisionInput
|
||||
${input.subjectType ?? null}, ${input.subjectId ?? null}, ${input.dedupeKey},
|
||||
${jsonb(input.evidence)}::jsonb, ${JSON.stringify(input.options ?? [])}::jsonb, ${input.taskId ?? null})
|
||||
on conflict (dedupe_key) where status in ('open', 'investigating', 'awaiting_user')
|
||||
do update set attempts = decision.attempts + 1, evidence = excluded.evidence, summary = excluded.summary,
|
||||
do update set attempts = decision.attempts + 1, evidence = coalesce(decision.evidence, '{}'::jsonb) || excluded.evidence ||
|
||||
case when excluded.dedupe_key like 'release_facts:%' then jsonb_build_object('missing',
|
||||
(select coalesce(jsonb_agg(distinct field), '[]'::jsonb) from jsonb_array_elements(
|
||||
coalesce(decision.evidence->'missing', '[]'::jsonb) || coalesce(excluded.evidence->'missing', '[]'::jsonb)) field))
|
||||
else '{}'::jsonb end, summary = excluded.summary,
|
||||
subject_type = coalesce(excluded.subject_type, decision.subject_type),
|
||||
subject_id = coalesce(excluded.subject_id, decision.subject_id),
|
||||
-- 重新判定时选项随最新证据刷新(如接管识别的候选);没给选项的保留原有(如 Agent 补的"重新校验")
|
||||
@@ -49,7 +53,13 @@ export async function upsertDecision(d: AoDeps, exec: Exec, input: DecisionInput
|
||||
await d.ctx.outbox.emit(
|
||||
exec,
|
||||
"decision.created",
|
||||
{ decisionId: row.id, kind: input.kind, severity: input.severity ?? "warning", title: input.title },
|
||||
{
|
||||
decisionId: row.id,
|
||||
kind: input.kind,
|
||||
severity: input.severity ?? "warning",
|
||||
title: input.title,
|
||||
dedupeKey: input.dedupeKey,
|
||||
},
|
||||
{ idempotencyKey: `decision.created:${row.id}` },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
AcquisitionSlot,
|
||||
CoverageColumn,
|
||||
SubscriptionCoverage,
|
||||
SubscriptionGroup,
|
||||
@@ -14,15 +13,18 @@ import {
|
||||
coverageRow,
|
||||
episodeKey,
|
||||
parseUnitKey,
|
||||
type QualityFacts,
|
||||
resolveAutoFill,
|
||||
type SlotSpec,
|
||||
seasonReleaseKey,
|
||||
selectSeasonRelease,
|
||||
subscriptionGroup,
|
||||
type UnitRange,
|
||||
upgradeSpec,
|
||||
} from "@umt/domain";
|
||||
import { slotSpecFromRow } from "@umt/mapping";
|
||||
import { toApi } from "../util.ts";
|
||||
import type { Exec } from "./common.ts";
|
||||
import { defaultChainIds, loadRuleCatalog, type RuleCatalog, resolveRuleChain } from "./rules.ts";
|
||||
import { INFLIGHT_PLAN_STATUSES, rangeOf, subParams } from "./subscriptions.ts";
|
||||
import { loadRuleCatalog, type RuleCatalog, resolveRuleChain } from "./rules.ts";
|
||||
import { INFLIGHT_PLAN_STATUSES, rangeOf, slotSpecWithRules, subParams } from "./subscriptions.ts";
|
||||
|
||||
// W9-E 覆盖矩阵与订阅总览(docs/design/subscriptions.md §1、§2.1、§5):
|
||||
// 一批订阅一次取齐 收录版本 / 资产 / 覆盖 / 分集账本 / 在途计划 / 内容单元 / 延后(固定 8 条查询,与订阅数无关),
|
||||
@@ -41,6 +43,7 @@ interface SlotModel {
|
||||
ruleGroupIds: string[];
|
||||
upgrade: { enabled: boolean; targetPri: number };
|
||||
requiredEditions: string[];
|
||||
spec: SlotSpec;
|
||||
}
|
||||
|
||||
interface RowModel {
|
||||
@@ -85,14 +88,16 @@ const segOf = (u: { segment: string } | undefined) =>
|
||||
const editionKey = (e: string) => `edition:${e}`;
|
||||
const ymd = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
/** 订阅有效自动补全:params.autoFill → 规则链上后者优先的 auto_fill → 系统缺省关 */
|
||||
/** 订阅有效自动补全:params.autoFill → 主版本收录链上后者优先的 auto_fill → 系统缺省关 */
|
||||
export function subscriptionAutoFill(
|
||||
cat: RuleCatalog,
|
||||
sub: Pick<SubscriptionRow, "id" | "params" | "media_kind" | "media_entity_id">,
|
||||
primarySlot?: Pick<AcquisitionSlotRow, "rule_group_ids">,
|
||||
): SubscriptionOverviewItem["autoFill"] & { chainIds: string[] } {
|
||||
const p = subParams(sub);
|
||||
const chain = resolveRuleChain(cat, {
|
||||
kind: "acquisition",
|
||||
slotRuleGroupIds: primarySlot?.rule_group_ids as string[] | undefined,
|
||||
subRuleGroupIds: p.ruleGroupIds,
|
||||
subscriptionId: sub.id,
|
||||
mediaKind: sub.media_kind,
|
||||
@@ -175,9 +180,10 @@ export async function buildSubscriptionModels(
|
||||
unit_keys: unknown;
|
||||
plan_slot_id: string | null;
|
||||
}>`
|
||||
select a.id, a.media_entity_id, a.content_edition, a.media_info->'_umt'->'quality' as quality,
|
||||
select a.id, a.media_entity_id, a.content_edition, coalesce(r.parsed, '{}'::jsonb) || coalesce(a.media_info->'_umt'->'quality', '{}'::jsonb) as quality,
|
||||
a.media_info->'_umt'->'unitKeys' as unit_keys, p.slot_id as plan_slot_id
|
||||
from media_asset a
|
||||
left join source_release r on r.id = a.source_release_id
|
||||
left join download_task dt on dt.id = a.download_task_id
|
||||
left join acquisition_plan p on p.id = dt.acquisition_plan_id
|
||||
where a.status = 'active' and a.parent_asset_id is null
|
||||
@@ -205,7 +211,15 @@ export async function buildSubscriptionModels(
|
||||
.leftJoin("download_task as dt", (j) =>
|
||||
j.onRef("dt.acquisition_plan_id", "=", "p.id").on("dt.removed_at", "is", null),
|
||||
)
|
||||
.select(["p.id", "p.slot_id", "p.status", "p.unit_keys", "dt.status as dt_status"])
|
||||
.leftJoin("source_release as r", "r.id", "p.source_release_id")
|
||||
.select([
|
||||
"p.id",
|
||||
"p.slot_id",
|
||||
"p.status",
|
||||
"p.unit_keys",
|
||||
"dt.status as dt_status",
|
||||
sql<QualityFacts>`coalesce(p.evaluation->'quality', r.parsed, '{}'::jsonb)`.as("quality"),
|
||||
])
|
||||
.where("p.slot_id", "in", slotIds)
|
||||
.where("p.status", "in", [...INFLIGHT_PLAN_STATUSES])
|
||||
.execute(),
|
||||
@@ -251,6 +265,9 @@ export async function buildSubscriptionModels(
|
||||
? "verifying"
|
||||
: "downloading";
|
||||
if (!r.slot_id) continue;
|
||||
const resolutions = ((slots.find((s) => s.id === r.slot_id)?.hard_conditions ?? {}) as { resolutions?: string[] })
|
||||
.resolutions;
|
||||
if (resolutions?.length && !resolutions.includes(r.quality.resolution ?? "")) continue;
|
||||
const list = inflightBySlot.get(r.slot_id) ?? [];
|
||||
const prev = list.find((x) => x.id === r.id);
|
||||
if (!prev) list.push({ id: r.id, stage, unitKeys: r.unit_keys });
|
||||
@@ -268,10 +285,11 @@ export async function buildSubscriptionModels(
|
||||
/** 资产集合 → 覆盖事实(单元键 → 资产/质量) */
|
||||
const coveredOf = (assetSet: Iterable<string>, ledger: typeof ledgerRows): Map<string, CoveredFact> => {
|
||||
const m = new Map<string, CoveredFact>();
|
||||
const allowedAssets = new Set(assetSet);
|
||||
const put = (k: string, f: CoveredFact, override = false) => {
|
||||
if (override || !m.has(k)) m.set(k, f);
|
||||
};
|
||||
for (const id of assetSet) {
|
||||
for (const id of allowedAssets) {
|
||||
const a = assetById.get(id);
|
||||
if (!a) continue;
|
||||
const q = qualityOf(a.quality);
|
||||
@@ -290,6 +308,7 @@ export async function buildSubscriptionModels(
|
||||
}
|
||||
// 分集账本优先(质量与 pri 按收录版本记)
|
||||
for (const r of ledger) {
|
||||
if (!r.media_asset_id || !allowedAssets.has(r.media_asset_id) || !assetById.has(r.media_asset_id)) continue;
|
||||
const f = { assetId: r.media_asset_id, quality: qualityOf(r.quality), pri: r.pri };
|
||||
if (r.kind === "episode" && r.episode_number !== null)
|
||||
put(episodeKey(r.season_number ?? 1, r.episode_number), f, true);
|
||||
@@ -300,36 +319,49 @@ export async function buildSubscriptionModels(
|
||||
|
||||
for (const sub of subs) {
|
||||
const entity = entities.find((e) => e.id === sub.media_entity_id) ?? null;
|
||||
const af = subscriptionAutoFill(cat, sub);
|
||||
const p = subParams(sub);
|
||||
const explicitGroups = p.ruleGroupIds ?? [];
|
||||
const subSlots = entity ? slotsOfSub(slotsByEntity.get(entity.id) ?? [], sub) : [];
|
||||
const af = subscriptionAutoFill(cat, sub, subSlots[0]);
|
||||
const entitySlotCount = entity ? (slotsByEntity.get(entity.id) ?? []).length : 0;
|
||||
const slotRuleIds = [...new Set(subSlots.flatMap((s) => (s.rule_group_ids ?? []) as string[]))];
|
||||
const ruleGroupIds = explicitGroups.length
|
||||
? explicitGroups
|
||||
: slotRuleIds.length
|
||||
? slotRuleIds
|
||||
: defaultChainIds(cat, "acquisition");
|
||||
const visibleChainIds = (slot?: AcquisitionSlotRow) =>
|
||||
resolveRuleChain(cat, {
|
||||
kind: "acquisition",
|
||||
slotRuleGroupIds: slot?.rule_group_ids as string[] | undefined,
|
||||
subRuleGroupIds: p.ruleGroupIds,
|
||||
subscriptionId: sub.id,
|
||||
mediaKind: sub.media_kind,
|
||||
entityId: sub.media_entity_id ?? undefined,
|
||||
}).ids.filter((gid) => !(cat.groups.get(gid)?.scope as { subscriptionId?: string } | null)?.subscriptionId);
|
||||
const ruleGroupIds = [...new Set(subSlots.length ? subSlots.flatMap(visibleChainIds) : visibleChainIds())];
|
||||
const models: SlotModel[] = subSlots.map((s, i) => {
|
||||
const spec = slotSpecFromRow(toApi<AcquisitionSlot>(s));
|
||||
const spec = upgradeSpec(slotSpecWithRules(cat, s, sub, entity!));
|
||||
const cuts = (spec.completion && spec.completion.kind === "movie" ? spec.completion.cuts : undefined) ?? [];
|
||||
return {
|
||||
row: s,
|
||||
primary: i === 0,
|
||||
autoFill: i === 0 || af.enabled,
|
||||
ruleGroupIds: (s.rule_group_ids ?? []) as string[],
|
||||
ruleGroupIds: visibleChainIds(s),
|
||||
upgrade: { enabled: !!spec.upgrade?.enabled, targetPri: spec.upgrade?.targetPri ?? 100 },
|
||||
requiredEditions: cuts.length ? cuts.map(String) : ["default"],
|
||||
spec,
|
||||
};
|
||||
});
|
||||
const assetSetOf = (s: AcquisitionSlotRow): Set<string> => {
|
||||
const set = new Set<string>();
|
||||
const resolutions = ((s.hard_conditions ?? {}) as { resolutions?: string[] }).resolutions;
|
||||
const allowed = (id: string) => {
|
||||
const a = assetById.get(id);
|
||||
return !!a && (!resolutions?.length || resolutions.includes((a.quality as QualityFacts)?.resolution ?? ""));
|
||||
};
|
||||
for (const a of assets.rows) {
|
||||
if (!allowed(a.id)) continue;
|
||||
if (a.plan_slot_id === s.id || a.id === s.current_asset_id) set.add(a.id);
|
||||
else if (entitySlotCount <= 1 && a.media_entity_id === s.media_entity_id) set.add(a.id);
|
||||
}
|
||||
for (const r of ledgerBySlot.get(s.id) ?? []) if (r.media_asset_id) set.add(r.media_asset_id);
|
||||
for (const r of ledgerBySlot.get(s.id) ?? [])
|
||||
if (r.media_asset_id && allowed(r.media_asset_id)) set.add(r.media_asset_id);
|
||||
return set;
|
||||
};
|
||||
const entityAssets = entity ? assets.rows.filter((a) => a.media_entity_id === entity.id).map((a) => a.id) : [];
|
||||
@@ -542,6 +574,30 @@ export async function buildSubscriptionModels(
|
||||
today,
|
||||
upgrade: m.upgrade,
|
||||
});
|
||||
if (!isMovie && m.spec.upgrade?.consistency === "season_release") {
|
||||
const targets = new Map<number, string | null>();
|
||||
for (const season of seasons) {
|
||||
if (season.seasonNumber === null) continue;
|
||||
const n = season.seasonNumber;
|
||||
const versions = r.cells
|
||||
.filter((c) => parseUnitKey(c.unitKey)?.season === n && c.assetId)
|
||||
.map((c) => ({
|
||||
...((assetById.get(c.assetId!)?.quality ?? {}) as QualityFacts),
|
||||
...(c.pri !== null ? { pri: c.pri } : {}),
|
||||
}));
|
||||
const live = inflightRows.find(
|
||||
(p) => p.slot_id === m.row.id && p.unit_keys.some((k) => parseUnitKey(k)?.season === n),
|
||||
);
|
||||
targets.set(n, seasonReleaseKey(live?.quality ?? selectSeasonRelease(m.spec, versions) ?? {}));
|
||||
}
|
||||
for (const cell of r.cells) {
|
||||
const season = parseUnitKey(cell.unitKey)?.season;
|
||||
if (season === undefined || !cell.assetId) continue;
|
||||
const target = targets.get(season);
|
||||
if (!target || seasonReleaseKey((assetById.get(cell.assetId)?.quality ?? {}) as QualityFacts) !== target)
|
||||
cell.upgradable = true;
|
||||
}
|
||||
}
|
||||
if (isMovie) {
|
||||
// 电影:该收录版本不要求的内容版本不算缺
|
||||
for (const c of r.cells)
|
||||
|
||||
@@ -1011,6 +1011,19 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats,
|
||||
continue;
|
||||
}
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const latest = await trx
|
||||
.selectFrom("download_task")
|
||||
.selectAll()
|
||||
.where("id", "=", row.id)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (
|
||||
!latest?.managed ||
|
||||
latest.removed_at ||
|
||||
latest.status === "removed" ||
|
||||
(latest.last_synced_at && latest.last_synced_at > now)
|
||||
)
|
||||
return;
|
||||
if (still) {
|
||||
// 管理标签被移除:停止后续自动动作,保留历史与资产
|
||||
await trx
|
||||
@@ -1018,21 +1031,25 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats,
|
||||
.set({ managed: false, tags: still.tags, last_synced_at: now, missing_since: null, missing_count: 0 })
|
||||
.where("id", "=", row.id)
|
||||
.execute();
|
||||
} else if (row.missing_count + 1 < MISSING_CONFIRM_ROUNDS) {
|
||||
} else if (latest.missing_count + 1 < MISSING_CONFIRM_ROUNDS) {
|
||||
// 列表与按 hash 都查不到:先记缺席(qB 刚重启、任务还没加载完时会短暂返回空 / 部分列表)
|
||||
await trx
|
||||
.updateTable("download_task")
|
||||
.set({ missing_since: row.missing_since ?? now, missing_count: row.missing_count + 1, last_synced_at: now })
|
||||
.set({
|
||||
missing_since: latest.missing_since ?? now,
|
||||
missing_count: latest.missing_count + 1,
|
||||
last_synced_at: now,
|
||||
})
|
||||
.where("id", "=", row.id)
|
||||
.execute();
|
||||
} else {
|
||||
await trx
|
||||
.updateTable("download_task")
|
||||
.set({ status: "removed", removed_at: now, last_synced_at: now, missing_count: row.missing_count + 1 })
|
||||
.set({ status: "removed", removed_at: now, last_synced_at: now, missing_count: latest.missing_count + 1 })
|
||||
.where("id", "=", row.id)
|
||||
.execute();
|
||||
await emitStatus(d, trx, row.id, row.status, "removed", null);
|
||||
if (row.acquisition_plan_id) await releaseRemovedPlan(d, trx, row.acquisition_plan_id);
|
||||
await emitStatus(d, trx, row.id, latest.status, "removed", null);
|
||||
if (latest.acquisition_plan_id) await releaseRemovedPlan(d, trx, latest.acquisition_plan_id);
|
||||
stats.removed++;
|
||||
}
|
||||
});
|
||||
@@ -1052,7 +1069,9 @@ async function observe(d: AoDeps, seen: DownloadTaskRow, t: QbTorrent, now: Date
|
||||
.where("id", "=", seen.id)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!row || row.removed_at || row.status === "removed") return false;
|
||||
// 多轮同步可交错;行锁之外取得的旧 qB 快照不能覆盖新一轮的标签或状态。
|
||||
if (!row || row.removed_at || row.status === "removed" || (row.last_synced_at && row.last_synced_at > now))
|
||||
return false;
|
||||
return observeLocked(d, trx, row, t, now, stats);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function autoFillCheck(
|
||||
if (!primary || slots.length <= 1 || primary.id === slot.id) return { action: "normal", planUnits };
|
||||
if (input.force) return { action: "normal", planUnits };
|
||||
|
||||
const af = subscriptionAutoFill(input.catalog, sub);
|
||||
const af = subscriptionAutoFill(input.catalog, sub, primary);
|
||||
if (!af.enabled)
|
||||
return {
|
||||
action: "defer",
|
||||
|
||||
@@ -20,16 +20,10 @@ import { errMessage, jsonb } from "../util.ts";
|
||||
import { type AoDeps, upsertDecision } from "./common.ts";
|
||||
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
|
||||
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
|
||||
import { finalizeRecord, removeMovedDownload } from "./organize-finalize.ts";
|
||||
import { finalizeRecord, postprocessRecord, removeMovedDownload } from "./organize-finalize.ts";
|
||||
import { emitRecord } from "./organize-plan.ts";
|
||||
import type { FrozenPlan } from "./organize-types.ts";
|
||||
import {
|
||||
cleanupReplacement,
|
||||
quarantineNotVideo,
|
||||
settleSamePathReplacement,
|
||||
stagedReplacementBackup,
|
||||
} from "./replacement.ts";
|
||||
import { scrapeAfterImport } from "./scrape.ts";
|
||||
import { stagedReplacementBackup } from "./replacement.ts";
|
||||
|
||||
// ── 执行 ──
|
||||
|
||||
@@ -86,17 +80,8 @@ export async function executeRecord(
|
||||
.where("id", "=", recordId)
|
||||
.executeTakeFirstOrThrow();
|
||||
if (rec.status !== "running" && rec.status !== "queued") {
|
||||
await settleSamePathReplacement(d, rec.plan as unknown as FrozenPlan, recordId);
|
||||
await cleanupReplacement(d, rec.plan as unknown as FrozenPlan, recordId);
|
||||
await quarantineNotVideo(d, rec.plan as unknown as FrozenPlan, recordId);
|
||||
const frozen = rec.plan as unknown as FrozenPlan;
|
||||
// 与 finalizeRecord 同一口径(按设置 / 本次选项);排队按整理记录幂等,重放不会重复刮
|
||||
if (["succeeded", "partial"].includes(rec.status)) {
|
||||
await scrapeAfterImport(d, frozen.entityId, recordId, {
|
||||
force: frozen.metadataMode === "local",
|
||||
enabled: frozen.scrapeAfterImport,
|
||||
});
|
||||
}
|
||||
await postprocessRecord(d, frozen, recordId, rec.status);
|
||||
// Move 成功但移除 qB 任务没成功(qB 暂时连不上 / 收尾后进程退出):重放时补做
|
||||
if (rec.action === "move" && rec.status === "succeeded" && rec.download_task_id) {
|
||||
const dt = await db
|
||||
@@ -329,7 +314,7 @@ export async function executeRecord(
|
||||
mbps: r.durationMs > 0 ? Math.round(((r.bytes ?? pf.size) / 1e6 / r.durationMs) * 1000) : null,
|
||||
hashVia: r.hashVia ?? null,
|
||||
resumed: info.resumed,
|
||||
// 执行方式:subprocess(workerKind script=deno run / self=编译二进制 --role fsworker)或 inprocess 回退
|
||||
// 执行方式:subprocess(workerKind script=bun / self=编译二进制 --role fsworker)或 inprocess 回退
|
||||
fsproxy: fsr.proxy.stats(),
|
||||
},
|
||||
"organize.transfer",
|
||||
@@ -515,7 +500,7 @@ export async function trashReplacedOld(
|
||||
}
|
||||
await rmdir(dirname(old.path)).catch(() => undefined);
|
||||
}
|
||||
const days = Number(Deno.env.get("FILES_TRASH_RETENTION_DAYS") ?? 7) || 7;
|
||||
const days = Number(process.env.FILES_TRASH_RETENTION_DAYS ?? 7) || 7;
|
||||
await d.ctx.db
|
||||
.insertInto("trash_item")
|
||||
.values({
|
||||
@@ -569,7 +554,7 @@ export function fsRuntime(d: AoDeps): FsRuntime {
|
||||
if (hit) return hit;
|
||||
const rt: FsRuntime = {
|
||||
proxy: new FsProxy({
|
||||
mode: c.FS_PROXY_MODE ?? "auto",
|
||||
mode: c.FS_PROXY_MODE ?? "subprocess",
|
||||
hangTimeoutMs: c.FS_HANG_TIMEOUT_MS ?? 120_000,
|
||||
quickTimeoutMs: c.FS_QUICK_TIMEOUT_MS ?? 30_000,
|
||||
progressIntervalMs: c.FS_PROGRESS_INTERVAL_MS ?? 1000,
|
||||
|
||||
@@ -435,7 +435,12 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
{ type: "verify_passed", complete: false },
|
||||
{ currentAssetId: verifiedAssets[0]!, reason: "verified" },
|
||||
);
|
||||
if (old && old !== verifiedAssets[0] && plan.purpose === "upgrade") {
|
||||
if (
|
||||
old &&
|
||||
old !== verifiedAssets[0] &&
|
||||
plan.purpose === "upgrade" &&
|
||||
!frozen.replacement?.assets.some((a) => a.assetId === old)
|
||||
) {
|
||||
// 升级:旧版本保留,处置只记待处理,不删
|
||||
await upsertDecision(d, trx, {
|
||||
kind: "other",
|
||||
@@ -525,17 +530,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
};
|
||||
});
|
||||
|
||||
await settleSamePathReplacement(d, frozenPre, recordId);
|
||||
await cleanupReplacement(d, frozenPre, recordId);
|
||||
await quarantineNotVideo(d, frozenPre, recordId);
|
||||
|
||||
// SCR-1:有资产入库时按设置自动刮削(写 NFO 与图片;排任务,不在这里等)
|
||||
if (result.status === "succeeded" || result.status === "partial") {
|
||||
await scrapeAfterImport(d, frozenPre.entityId ?? null, recordId, {
|
||||
force: frozenPre.metadataMode === "local",
|
||||
enabled: frozenPre.scrapeAfterImport,
|
||||
});
|
||||
}
|
||||
await postprocessRecord(d, frozenPre, recordId, result.status);
|
||||
// Move 完成:移除 qB 任务记录,结束下载侧管理(不删数据)
|
||||
if (result.move && result.dt) await removeMovedDownload(d, result.dt);
|
||||
return {
|
||||
@@ -546,6 +541,28 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录终态提交后的文件处置与刮削入队,首次完成和终态重放共用。
|
||||
* 失败记录也要恢复旧文件、隔离伪视频;部分成功可以刮削。
|
||||
* Move 后移除 qB 由调用方按完整成功判断,不与文件处置混在一起。
|
||||
*/
|
||||
export async function postprocessRecord(
|
||||
d: AoDeps,
|
||||
frozen: FrozenPlan,
|
||||
recordId: string,
|
||||
status: ExecuteResult["status"],
|
||||
): Promise<void> {
|
||||
await settleSamePathReplacement(d, frozen, recordId);
|
||||
await cleanupReplacement(d, frozen, recordId);
|
||||
await quarantineNotVideo(d, frozen, recordId);
|
||||
if (status === "succeeded" || status === "partial") {
|
||||
await scrapeAfterImport(d, frozen.entityId ?? null, recordId, {
|
||||
force: frozen.metadataMode === "local",
|
||||
enabled: frozen.scrapeAfterImport,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move 成功后移除 qB 任务(不删数据)并结束纳管。失败只告警:executeRecord 重放已结束记录、
|
||||
* 整理巡检(recoverStalledRecords)会对"move 成功但下载任务未移除"的记录再试。
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import type { OrganizePlan } from "@umt/domain";
|
||||
import { hasHook, interceptHook, rewriteHook } from "../../../hooks/index.ts";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
|
||||
// 整理计划上的链式钩子(ADR-006):organize.rename 改写主视频目标文件名、organize.intercept 拒绝整理。
|
||||
// 都在计划生成之后、写整理记录之前调用;没有注册钩子时什么都不做。
|
||||
|
||||
const BAD_NAME = /[\\/\0]/;
|
||||
const usableName = (n: unknown): boolean =>
|
||||
typeof n === "string" && n.trim() !== "" && !BAD_NAME.test(n) && n !== "." && n !== "..";
|
||||
|
||||
/**
|
||||
* organize.rename:逐个主视频把模板生成的目标文件名交给钩子改写,同主干的附属文件(字幕、NFO 等)跟着换主干。
|
||||
* 只改文件名不改目录;返回带路径分隔符或空名的钩子结果跳过。改名后撞名的项转 hold + duplicate_target(与保留原名同一处理)。
|
||||
* 改名后的目标是否已存在由执行阶段按冲突处理(计划期的存在性探测在改名之前)。
|
||||
*/
|
||||
export async function applyRenameHook(
|
||||
ctx: Pick<ServerContext, "hooks">,
|
||||
plan: OrganizePlan,
|
||||
base: { mediaEntityId: string; mediaType: "movie" | "tv" },
|
||||
): Promise<number> {
|
||||
if (!hasHook(ctx, "organize.rename")) return 0;
|
||||
const renamed = new Map<string, string>();
|
||||
for (const main of plan.files.filter((f) => f.role === "main")) {
|
||||
const name = basename(main.dst);
|
||||
// 返回带路径分隔符或空名的钩子结果跳过(记日志),其余钩子照常
|
||||
const next = await rewriteHook(
|
||||
ctx,
|
||||
"organize.rename",
|
||||
name,
|
||||
{
|
||||
targetPath: main.dst,
|
||||
sourcePath: main.src,
|
||||
mediaEntityId: base.mediaEntityId,
|
||||
mediaType: base.mediaType,
|
||||
season: main.season ?? null,
|
||||
episodes: main.episodes ?? null,
|
||||
},
|
||||
usableName,
|
||||
);
|
||||
if (next === name) continue;
|
||||
const oldStem = name.slice(0, name.length - extname(name).length);
|
||||
const newStem = next.slice(0, next.length - extname(next).length);
|
||||
const to = join(dirname(main.dst), next);
|
||||
renamed.set(main.dst, to);
|
||||
main.dst = to;
|
||||
for (const f of plan.files) {
|
||||
if (f.mainSrc !== main.src) continue;
|
||||
const side = basename(f.dst);
|
||||
if (!side.startsWith(oldStem)) continue;
|
||||
const nd = join(dirname(f.dst), newStem + side.slice(oldStem.length));
|
||||
renamed.set(f.dst, nd);
|
||||
f.dst = nd;
|
||||
}
|
||||
}
|
||||
if (renamed.size === 0) return 0;
|
||||
for (const c of plan.conflicts) if (c.dst && renamed.has(c.dst)) c.dst = renamed.get(c.dst)!;
|
||||
for (const pc of plan.preconditions)
|
||||
if (pc.kind === "target_absent" || pc.kind === "verify_after_copy")
|
||||
pc.paths = pc.paths.map((p) => renamed.get(p) ?? p);
|
||||
const byDst = new Map<string, OrganizePlan["files"]>();
|
||||
for (const f of plan.files) byDst.set(f.dst, [...(byDst.get(f.dst) ?? []), f]);
|
||||
for (const [dst, fs] of byDst) {
|
||||
if (fs.length < 2 || plan.conflicts.some((c) => c.kind === "duplicate_target" && c.dst === dst)) continue;
|
||||
for (const f of fs) f.action = "hold";
|
||||
plan.conflicts.push({ kind: "duplicate_target", blocking: true, dst, detail: fs.map((f) => f.src).join(" | ") });
|
||||
}
|
||||
plan.requiresApproval = plan.conflicts.some((c) => c.blocking);
|
||||
return renamed.size;
|
||||
}
|
||||
|
||||
/** organize.intercept:返回拒绝原因(含拒绝的钩子名),放行返回 null */
|
||||
export async function organizeIntercept(
|
||||
ctx: Pick<ServerContext, "hooks">,
|
||||
plan: OrganizePlan,
|
||||
base: {
|
||||
mediaEntityId: string;
|
||||
mediaType: "movie" | "tv";
|
||||
action: string;
|
||||
trigger: string;
|
||||
downloadTaskId: string | null;
|
||||
},
|
||||
) {
|
||||
return interceptHook(ctx, "organize.intercept", () => ({
|
||||
...base,
|
||||
files: plan.files.map((f) => ({ src: f.src, dst: f.dst, kind: f.kind, role: f.role })),
|
||||
}));
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
replacementScope,
|
||||
resolveTargetDirectory,
|
||||
type SourceFile,
|
||||
selectAcquisitionPolicy,
|
||||
sidecarStem,
|
||||
titleMismatches,
|
||||
} from "@umt/domain";
|
||||
@@ -43,9 +44,10 @@ import {
|
||||
} from "../../sources-identity/service/episode-resolution.ts";
|
||||
import type { Layout } from "../../sources-identity/service/episode-structure.ts";
|
||||
import { wordsOption } from "../../sources-identity/service/identify-words-cache.ts";
|
||||
import { parsedOf, unitKeysOf } from "../../sources-identity/service/release-facts.ts";
|
||||
import { errMessage, hashJson, jsonb, toApi } from "../util.ts";
|
||||
import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts";
|
||||
import { classifyEntity } from "./classification.ts";
|
||||
import { collectionContext, publishedAcquisitionPolicies } from "./collection-strategy.ts";
|
||||
import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts";
|
||||
import { applySlotEvent } from "./downloads.ts";
|
||||
import { emitOrganizationFinished } from "./lifecycle-events.ts";
|
||||
@@ -53,7 +55,6 @@ import { effectiveNaming, ensureNamingVersion, loadNamingEntity, namingByVersion
|
||||
import { resolveOrganizeSource } from "./organize-config.ts";
|
||||
import { PathGuardError } from "./organize-guard.ts";
|
||||
import { successfulOrganizeCounts } from "./organize-history.ts";
|
||||
import { applyRenameHook, organizeIntercept } from "./organize-hooks.ts";
|
||||
import { planTransfer } from "./organize-transfer.ts";
|
||||
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
|
||||
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
|
||||
@@ -77,9 +78,11 @@ import {
|
||||
type TakeoverIdentify,
|
||||
takeoverSummary,
|
||||
} from "./takeover.ts";
|
||||
import { automaticReplacementAssets } from "./upgrade-replacement.ts";
|
||||
|
||||
export const qualityOf = (p: ParsedRelease): QualityFacts => ({
|
||||
...(p.resolution ? { resolution: p.resolution } : {}),
|
||||
...(p.videoCodec ? { videoCodec: p.videoCodec } : {}),
|
||||
...(p.source ? { source: p.source } : {}),
|
||||
hdr: p.hdr,
|
||||
...(p.releaseGroup ? { releaseGroup: p.releaseGroup } : {}),
|
||||
@@ -365,6 +368,9 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
mediaEntityId?: string | null;
|
||||
importMode?: "normal" | "replace";
|
||||
replacementAssetIds?: string[];
|
||||
automaticReplacement?: boolean;
|
||||
quality?: QualityFacts;
|
||||
unitKeys?: string[];
|
||||
}
|
||||
| undefined)
|
||||
: undefined;
|
||||
@@ -467,17 +473,25 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
const mediaType: "movie" | "tv" = entity.kind === "movie" ? "movie" : "tv";
|
||||
|
||||
const inPlace = req.action === "in_place";
|
||||
const importMode = req.importMode ?? evaluation?.importMode ?? "normal";
|
||||
let replacementIds = req.replacementAssetIds ?? evaluation?.replacementAssetIds ?? [];
|
||||
const automaticReplacement = !evaluation?.manual && evaluation?.automaticReplacement && !req.importMode && slotId;
|
||||
if (automaticReplacement && evaluation.quality) {
|
||||
const slot = await db
|
||||
.selectFrom("acquisition_slot")
|
||||
.selectAll()
|
||||
.where("id", "=", slotId!)
|
||||
.executeTakeFirstOrThrow();
|
||||
replacementIds = await automaticReplacementAssets(db, slot, evaluation.unitKeys ?? [], evaluation.quality);
|
||||
}
|
||||
const importMode =
|
||||
req.importMode ?? evaluation?.importMode ?? (automaticReplacement && replacementIds.length ? "replace" : "normal");
|
||||
if (inPlace && importMode === "replace") return fail("in_place_cannot_replace", {}, entity.id);
|
||||
let replacement: FrozenReplacement | undefined;
|
||||
if (importMode === "replace") {
|
||||
try {
|
||||
replacement = await freezeReplacement(
|
||||
d,
|
||||
entity.id,
|
||||
mediaType,
|
||||
req.replacementAssetIds ?? evaluation?.replacementAssetIds ?? [],
|
||||
);
|
||||
replacement = await freezeReplacement(d, entity.id, mediaType, replacementIds);
|
||||
if (automaticReplacement && evaluation?.quality)
|
||||
replacement.automatic = { slotId: slotId!, quality: evaluation.quality };
|
||||
} catch (error) {
|
||||
return fail("replacement_invalid", { message: errMessage(error) }, entity.id);
|
||||
}
|
||||
@@ -531,7 +545,29 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
reason: "classification_unavailable",
|
||||
};
|
||||
}
|
||||
const policy = (await publishedAcquisitionPolicies(db))[0];
|
||||
const slotPolicyId = slotId
|
||||
? (await db.selectFrom("acquisition_slot").select("policy_revision_id").where("id", "=", slotId).executeTakeFirst())
|
||||
?.policy_revision_id
|
||||
: null;
|
||||
const selectedPolicy = slotPolicyId
|
||||
? await db.selectFrom("policy_revision").selectAll().where("id", "=", slotPolicyId).executeTakeFirst()
|
||||
: undefined;
|
||||
const context = classification
|
||||
? {
|
||||
mediaKind:
|
||||
entity.kind === "movie"
|
||||
? ("movie" as const)
|
||||
: entity.kind === "variety"
|
||||
? ("variety" as const)
|
||||
: ("series" as const),
|
||||
category: classification.category,
|
||||
}
|
||||
: await collectionContext(d, entity.id);
|
||||
const policy = selectedPolicy
|
||||
? { rules: selectedPolicy.rules as import("@umt/contracts").AcquisitionPolicyRules }
|
||||
: context
|
||||
? selectAcquisitionPolicy(await publishedAcquisitionPolicies(db), context)
|
||||
: undefined;
|
||||
let action: "copy" | "move" | "in_place" =
|
||||
req.action ?? sourceConfig?.action ?? policy?.rules.organizeMode ?? orgSettings.defaultMode;
|
||||
const site = rel
|
||||
@@ -671,6 +707,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
files.filter((f) => !overrideFor(req.fileOverrides, f.path) && fileKindOf(basename(f.path)) === "video"),
|
||||
);
|
||||
const words = await wordsOption(d.ctx.db);
|
||||
const releaseQuality = rel ? qualityOf(parsedOf(rel)) : undefined;
|
||||
for (const f of files) {
|
||||
let parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot), words);
|
||||
if (parsed.fileKind !== "video") continue;
|
||||
@@ -722,7 +759,15 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
unit === undefined ? parsed : unit ? { ...parsed, season: unit.season, episodes: unit.episodes } : null;
|
||||
const unitSeason = unit === undefined ? season : unit?.season;
|
||||
facts[f.path] = {
|
||||
quality: qualityOf(parsed),
|
||||
quality: {
|
||||
...qualityOf(parsed),
|
||||
...(!parsed.resolution && releaseQuality?.resolution ? { resolution: releaseQuality.resolution } : {}),
|
||||
...(!parsed.source && releaseQuality?.source
|
||||
? { source: releaseQuality.source, isEncode: releaseQuality.isEncode }
|
||||
: {}),
|
||||
...(!parsed.videoCodec && releaseQuality?.videoCodec ? { videoCodec: releaseQuality.videoCodec } : {}),
|
||||
...(!parsed.releaseGroup && releaseQuality?.releaseGroup ? { releaseGroup: releaseQuality.releaseGroup } : {}),
|
||||
},
|
||||
unitKeys: unitParsed
|
||||
? unitKeysOf({
|
||||
...unitParsed,
|
||||
@@ -942,19 +987,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
plan.requiresApproval = true;
|
||||
}
|
||||
}
|
||||
// 链式钩子 organize.rename(ADR-006):模板命名时可改写主视频目标文件名
|
||||
if (!inPlace && !keepNames) await applyRenameHook(d.ctx, plan, { mediaEntityId: entity.id, mediaType });
|
||||
if (keepNames && !inPlace) keepOriginalNames(plan);
|
||||
if (req.keepBoth && !inPlace) await keepBothTargets(plan);
|
||||
// 链式钩子 organize.intercept:返回原因即拒绝本次整理(记为失败)
|
||||
const veto = await organizeIntercept(d.ctx, plan, {
|
||||
mediaEntityId: entity.id,
|
||||
mediaType,
|
||||
action,
|
||||
trigger: req.trigger,
|
||||
downloadTaskId: dt?.id ?? null,
|
||||
});
|
||||
if (veto) return fail(`intercepted:${veto.reason}`, { hook: veto.by, reason: veto.reason }, entity.id);
|
||||
const frozen: FrozenPlan = {
|
||||
version: 1,
|
||||
...(req.scrapeAfterImport !== undefined ? { scrapeAfterImport: req.scrapeAfterImport } : {}),
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { AcquisitionPolicyRules, AcquisitionPolicyScope } from "@umt/contracts";
|
||||
import type { DB, Transaction } from "@umt/db";
|
||||
import { evaluateCandidate } from "@umt/domain";
|
||||
import { slotSpecFromRow } from "@umt/mapping";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import { contextOf, parsedOf } from "../../sources-identity/service/release-facts.ts";
|
||||
import { jsonb } from "../util.ts";
|
||||
import type { AoDeps } from "./common.ts";
|
||||
import {
|
||||
assertRuleGroupIds,
|
||||
entityOriginalLanguage,
|
||||
loadRuleCatalog,
|
||||
mergeHard,
|
||||
resolveRuleChain,
|
||||
upgradeRuleGroupIds,
|
||||
} from "./rules.ts";
|
||||
|
||||
/** 发布:旧 published → superseded/rolled_back;吊销旧授权;新版本生成持续授权 automation_grant */
|
||||
export async function publishRevision(
|
||||
trx: Transaction<DB>,
|
||||
id: string,
|
||||
policyKey: string,
|
||||
by: string,
|
||||
oldStatus: "superseded" | "rolled_back",
|
||||
) {
|
||||
const current = await trx
|
||||
.selectFrom("policy_revision")
|
||||
.select(["kind", "scope", "rules"])
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirstOrThrow();
|
||||
if (current.kind === "acquisition") {
|
||||
const scope = AcquisitionPolicyScope.safeParse(current.scope);
|
||||
const rules = AcquisitionPolicyRules.safeParse(current.rules);
|
||||
if (!scope.success || !rules.success) throw new ApiException("validation_failed", "收录策略范围或规则结构不合法");
|
||||
await assertRuleGroupIds(
|
||||
trx,
|
||||
[...(rules.data.defaultSlots ?? []), ...(rules.data.newTitle?.slots ?? [])].flatMap((t) => [
|
||||
...(t.ruleGroupIds ?? []),
|
||||
...upgradeRuleGroupIds(t.upgradeThreshold),
|
||||
]),
|
||||
);
|
||||
}
|
||||
const old = await trx
|
||||
.selectFrom("policy_revision")
|
||||
.select(["id"])
|
||||
.where("policy_key", "=", policyKey)
|
||||
.where("status", "=", "published")
|
||||
.execute();
|
||||
const now = new Date();
|
||||
for (const o of old as { id: string }[]) {
|
||||
await trx.updateTable("policy_revision").set({ status: oldStatus }).where("id", "=", o.id).execute();
|
||||
await trx
|
||||
.updateTable("automation_grant")
|
||||
.set({ status: "revoked", revoked_at: now, revoked_by: by })
|
||||
.where("policy_revision_id", "=", o.id)
|
||||
.where("status", "=", "active")
|
||||
.execute();
|
||||
}
|
||||
const row = await trx
|
||||
.updateTable("policy_revision")
|
||||
.set({ status: "published", published_at: now, published_by: by })
|
||||
.where("id", "=", id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
const rules = (row.rules ?? {}) as { budget?: unknown };
|
||||
await trx
|
||||
.insertInto("automation_grant")
|
||||
.values({
|
||||
action: `policy.${row.kind}`,
|
||||
policy_revision_id: row.id,
|
||||
scope: jsonb(row.scope),
|
||||
budget: jsonb(rules.budget ?? {}),
|
||||
granted_by: by,
|
||||
reason: `发布策略 ${policyKey} r${row.revision}`,
|
||||
})
|
||||
.execute();
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 预览:用规则里的 Slot 模板评估最近已识别的发布(确定性,不产生副作用) */
|
||||
export async function previewPolicy(d: AoDeps, rules: unknown) {
|
||||
const r = AcquisitionPolicyRules.safeParse(rules);
|
||||
const templates = r.success ? [...(r.data.defaultSlots ?? []), ...(r.data.newTitle?.slots ?? [])] : [];
|
||||
const rels = await d.ctx.db
|
||||
.selectFrom("source_release")
|
||||
.selectAll()
|
||||
.where("resolution_status", "=", "resolved")
|
||||
.orderBy("first_seen_at", "desc")
|
||||
.limit(200)
|
||||
.execute();
|
||||
let matched = 0;
|
||||
let excluded = 0;
|
||||
const samples: {
|
||||
sourceReleaseId: string;
|
||||
title: string;
|
||||
sizeBytes: number | null;
|
||||
template: string;
|
||||
decision: string;
|
||||
reasons: string[];
|
||||
}[] = [];
|
||||
const cat = await loadRuleCatalog(d.ctx.db);
|
||||
const entityIds = [...new Set(rels.map((r) => r.media_entity_id).filter((id): id is string => !!id))];
|
||||
const entities = new Map(
|
||||
(entityIds.length
|
||||
? await d.ctx.db.selectFrom("media_entity").select(["id", "kind"]).where("id", "in", entityIds).execute()
|
||||
: []
|
||||
).map((e) => [e.id, e]),
|
||||
);
|
||||
const languages = new Map<string, string | undefined>();
|
||||
for (const rel of rels) {
|
||||
const parsed = parsedOf(rel);
|
||||
const entity = rel.media_entity_id ? entities.get(rel.media_entity_id) : undefined;
|
||||
const ctx = contextOf(rel);
|
||||
if (entity) {
|
||||
if (!languages.has(entity.id)) languages.set(entity.id, await entityOriginalLanguage(d.ctx.db, entity.id));
|
||||
ctx.originalLanguage = languages.get(entity.id);
|
||||
}
|
||||
for (const t of templates) {
|
||||
const spec = slotSpecFromRow({
|
||||
id: "preview",
|
||||
name: t.name,
|
||||
hardConditions: t.hardConditions ?? {},
|
||||
softPreferences: t.softPreferences ?? {},
|
||||
upgradeThreshold: t.upgradeThreshold ?? {},
|
||||
completion: t.completion ?? {},
|
||||
fallback: t.fallback ?? {},
|
||||
});
|
||||
const chain = resolveRuleChain(cat, {
|
||||
kind: "acquisition",
|
||||
slotRuleGroupIds: t.ruleGroupIds,
|
||||
upgrade: false,
|
||||
mediaKind: entity?.kind === "movie" ? "movie" : entity?.kind === "variety" ? "variety" : "series",
|
||||
entityId: entity?.id,
|
||||
siteId: rel.site_id,
|
||||
});
|
||||
spec.hard = mergeHard(spec.hard, chain.hard);
|
||||
if (chain.groups.length) spec.rules = { groups: chain.groups, atoms: chain.atoms };
|
||||
const ev = evaluateCandidate(spec, parsed, null, ctx);
|
||||
if (ev.decision === "accept") matched++;
|
||||
else excluded++;
|
||||
if (samples.length < 20) {
|
||||
const reasons =
|
||||
ev.decision === "reject"
|
||||
? ev.violations.map((v) => `${v.field}:${v.code}`)
|
||||
: ev.decision === "unknown_field"
|
||||
? ev.missing.map((m) => `missing:${m}`)
|
||||
: "reasons" in ev
|
||||
? (ev.reasons ?? [])
|
||||
: [];
|
||||
samples.push({
|
||||
sourceReleaseId: rel.id,
|
||||
title: rel.title,
|
||||
sizeBytes: rel.size_bytes,
|
||||
template: t.name,
|
||||
decision: ev.decision,
|
||||
reasons,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
evaluatedAt: new Date().toISOString(),
|
||||
sampleSize: rels.length,
|
||||
templates: templates.length,
|
||||
matched,
|
||||
excluded,
|
||||
samples,
|
||||
valid: r.success,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,13 @@ import { randomUUID } from "node:crypto";
|
||||
import { basename, dirname, join, relative } from "node:path";
|
||||
import { DEFAULT_ROOT_SENTINEL, type FileFacts } from "@umt/adapters";
|
||||
import { sql } from "@umt/db";
|
||||
import { type ReplacementScope, replacementCovers, replacementScope } from "@umt/domain";
|
||||
import {
|
||||
type QualityFacts,
|
||||
type ReplacementScope,
|
||||
replacementCovers,
|
||||
replacementScope,
|
||||
seasonReleaseKey,
|
||||
} from "@umt/domain";
|
||||
import { mkdir, readdir, realpath, rm } from "../../../storage/local-fs.ts";
|
||||
import {
|
||||
isRemotePath,
|
||||
@@ -66,6 +72,7 @@ export interface ReplacementAsset {
|
||||
export interface FrozenReplacement {
|
||||
mode: "replace";
|
||||
assets: ReplacementAsset[];
|
||||
automatic?: { slotId: string; quality: QualityFacts };
|
||||
}
|
||||
|
||||
export async function freezeReplacement(
|
||||
@@ -235,6 +242,31 @@ export async function cleanupReplacement(d: AoDeps, frozen: FrozenPlan, recordId
|
||||
),
|
||||
];
|
||||
});
|
||||
// 分次洗版:本次核验成功后,可以用同保留版本、同套且已核验的文件补齐旧合集覆盖。
|
||||
const automatic = frozen.replacement.automatic;
|
||||
const targetKey = automatic ? seasonReleaseKey(automatic.quality) : null;
|
||||
if (automatic && targetKey && successful.length && frozen.mediaType === "tv") {
|
||||
const existing = await sql<{ quality: QualityFacts; path: string; season: number; episode: number }>`
|
||||
select coalesce(r.parsed, '{}'::jsonb) || coalesce(a.media_info->'_umt'->'quality', '{}'::jsonb) as quality, l.relative_path as path, u.season_number as season, u.episode_number as episode
|
||||
from media_asset a
|
||||
left join source_release r on r.id = a.source_release_id
|
||||
join asset_location l on l.media_asset_id = a.id and l.location_kind = 'library' and l.status = 'available'
|
||||
join asset_unit_coverage c on c.media_asset_id = a.id and c.coverage = 'full'
|
||||
join content_unit u on u.id = c.content_unit_id
|
||||
where a.media_entity_id = ${frozen.entityId!} and a.status = 'active'
|
||||
and a.id not in (${sql.join(frozen.replacement.assets.map((a) => a.assetId))})
|
||||
and u.season_number is not null and u.episode_number is not null
|
||||
and (exists (select 1 from slot_unit su where su.slot_id = ${automatic.slotId} and su.media_asset_id = a.id)
|
||||
or exists (select 1 from download_task dt join acquisition_plan p on p.id = dt.acquisition_plan_id
|
||||
where dt.id = a.download_task_id and p.slot_id = ${automatic.slotId} and p.status = 'completed'))
|
||||
and exists (select 1 from organization_file_result f join organization_record rec on rec.id = f.organization_record_id
|
||||
where f.media_asset_id = a.id and f.role = 'main' and a.verified_at is not null and (f.status = 'succeeded' or (f.status = 'skipped' and f.skip_reason = 'already_in_library')) and rec.status in ('succeeded', 'partial'))`.execute(
|
||||
db,
|
||||
);
|
||||
for (const file of existing.rows)
|
||||
if (seasonReleaseKey(file.quality ?? {}) === targetKey)
|
||||
scopes.push(replacementScope(file.path, "tv", { season: file.season, episodes: [file.episode] }));
|
||||
}
|
||||
for (const old of frozen.replacement.assets) {
|
||||
if (successful.some((f) => f.media_asset_id === old.assetId)) continue;
|
||||
if (!replacementCovers(old.scope, scopes)) {
|
||||
@@ -595,7 +627,7 @@ export async function quarantineNotVideo(d: AoDeps, frozen: FrozenPlan, recordId
|
||||
const trash = join(rootReal, ".umt-trash", now.toISOString().slice(0, 10).replace(/-/g, ""), id, rel);
|
||||
const m = await fsr.move(abs, trash);
|
||||
if (m.status !== "moved") throw new Error(`移入回收站失败:${m.status}`);
|
||||
const days = Number(Deno.env.get("FILES_TRASH_RETENTION_DAYS") ?? 7) || 7;
|
||||
const days = Number(process.env.FILES_TRASH_RETENTION_DAYS ?? 7) || 7;
|
||||
await db
|
||||
.insertInto("trash_item")
|
||||
.values({
|
||||
|
||||
@@ -384,3 +384,25 @@ export async function previewRuleGroup(
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
/** 所有引用入口共享存在性校验,洗版引用与补齐引用使用相同口径。 */
|
||||
export async function assertRuleGroupIds(exec: Exec, ids: readonly string[]) {
|
||||
if (!ids.length) return;
|
||||
const rows = await exec
|
||||
.selectFrom("rule_group")
|
||||
.select("id")
|
||||
.where("id", "in", [...ids])
|
||||
.where("deleted_at", "is", null)
|
||||
.execute();
|
||||
const have = new Set(rows.map((r) => r.id));
|
||||
const missing = ids.filter((id) => !have.has(id));
|
||||
if (missing.length) throw new ApiException("bad_request", `规则组不存在:${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
export function upgradeRuleGroupIds(upgrade: unknown): string[] {
|
||||
const ids = (upgrade as { ruleGroupIds?: unknown } | null)?.ruleGroupIds;
|
||||
if (ids === undefined) return [];
|
||||
if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string" || !id))
|
||||
throw new ApiException("validation_failed", "洗版规则组引用必须是 ID 列表");
|
||||
return ids;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AcquisitionSlotRow, SourceReleaseRow } from "@umt/db";
|
||||
import { sql } from "@umt/db";
|
||||
import {
|
||||
currentPri,
|
||||
effectiveLadder,
|
||||
parseUnitKey,
|
||||
type QualityFacts,
|
||||
ruleChainOf,
|
||||
type SlotSpec,
|
||||
selectSeasonRelease,
|
||||
} from "@umt/domain";
|
||||
import { contextOf, parsedOf } from "../../sources-identity/service/release-facts.ts";
|
||||
import type { Exec } from "./common.ts";
|
||||
|
||||
/** 有成套洗版在途时保持其冻结规格;结束后才允许选择更高档的新套。 */
|
||||
export async function seasonReleaseTarget(
|
||||
exec: Exec,
|
||||
slot: AcquisitionSlotRow,
|
||||
season: number,
|
||||
spec: SlotSpec,
|
||||
candidate?: QualityFacts,
|
||||
originalLanguage?: string,
|
||||
): Promise<QualityFacts | undefined> {
|
||||
const allowed = (q: QualityFacts) =>
|
||||
!spec.hard.resolutions?.length || (!!q.resolution && spec.hard.resolutions.includes(q.resolution));
|
||||
const inflight = await exec
|
||||
.selectFrom("acquisition_plan as p")
|
||||
.innerJoin("source_release as r", "r.id", "p.source_release_id")
|
||||
.selectAll("r")
|
||||
.select(["p.unit_keys as planned_units", "p.evaluation"])
|
||||
.where("p.slot_id", "=", slot.id)
|
||||
.where("p.status", "in", ["reserved", "submitted"])
|
||||
.orderBy("p.created_at")
|
||||
.orderBy("p.id")
|
||||
.execute();
|
||||
const inSeason = (keys: string[]) => keys.some((k) => parseUnitKey(k)?.season === season);
|
||||
for (const plan of inflight) {
|
||||
const evaluation = plan.evaluation as { seasonRelease?: string; quality?: QualityFacts };
|
||||
if (inSeason(plan.planned_units) && evaluation.seasonRelease && evaluation.quality && allowed(evaluation.quality))
|
||||
return evaluation.quality;
|
||||
}
|
||||
const assets = await sql<{ quality: QualityFacts; source_release_id: string | null }>`
|
||||
select distinct coalesce(a.media_info->'_umt'->'quality', '{}'::jsonb) || coalesce(su.quality, '{}'::jsonb) as quality, a.source_release_id
|
||||
from media_asset a
|
||||
join asset_unit_coverage c on c.media_asset_id = a.id and c.coverage = 'full'
|
||||
join content_unit u on u.id = c.content_unit_id
|
||||
left join slot_unit su on su.media_asset_id = a.id and su.content_unit_id = u.id and su.slot_id = ${slot.id}
|
||||
left join download_task dt on dt.id = a.download_task_id
|
||||
left join acquisition_plan p on p.id = dt.acquisition_plan_id
|
||||
where a.status = 'active' and u.season_number = ${season}
|
||||
and (su.slot_id = ${slot.id} or p.slot_id = ${slot.id} or a.id = ${slot.current_asset_id}
|
||||
or (a.media_entity_id = ${slot.media_entity_id} and (select count(*) from acquisition_slot where media_entity_id = ${slot.media_entity_id}) = 1))`.execute(
|
||||
exec,
|
||||
);
|
||||
const relIds = [...new Set(assets.rows.map((a) => a.source_release_id).filter((id): id is string => !!id))];
|
||||
const rels = new Map(
|
||||
(relIds.length ? await exec.selectFrom("source_release").selectAll().where("id", "in", relIds).execute() : []).map(
|
||||
(r) => [r.id, r],
|
||||
),
|
||||
);
|
||||
const facts = (r: SourceReleaseRow, delivered?: QualityFacts): QualityFacts => {
|
||||
const parsed = { ...parsedOf(r), ...delivered };
|
||||
const ctx = { ...contextOf(r), ...(originalLanguage ? { originalLanguage } : {}) };
|
||||
return { ...parsed, pri: effectiveLadder(spec, parsed, ctx, ruleChainOf(spec, parsed, ctx)).pri };
|
||||
};
|
||||
const versions: QualityFacts[] = candidate ? [candidate] : [];
|
||||
for (const a of assets.rows) {
|
||||
const rel = a.source_release_id ? rels.get(a.source_release_id) : undefined;
|
||||
const q = rel ? facts(rel, a.quality) : { ...a.quality, pri: undefined };
|
||||
versions.push({ ...q, pri: currentPri(spec, q) });
|
||||
}
|
||||
for (const r of inflight) if (inSeason(r.planned_units)) versions.push(facts(r));
|
||||
return selectSeasonRelease(spec, versions.filter(allowed));
|
||||
}
|
||||
@@ -226,7 +226,7 @@ export async function applySlotPreset(
|
||||
if (sameJson(a, b)) return 0;
|
||||
const slots = await exec
|
||||
.selectFrom("acquisition_slot")
|
||||
.select(["id", "hard_conditions", "soft_preferences", "revision"])
|
||||
.select(["id", "hard_conditions", "soft_preferences", "revision", "policy_revision_id"])
|
||||
.where("subscription_id", "=", input.subscriptionId)
|
||||
.execute();
|
||||
let n = 0;
|
||||
@@ -238,7 +238,17 @@ export async function applySlotPreset(
|
||||
for (const s of slots) {
|
||||
const curHard = (s.hard_conditions ?? {}) as Record<string, unknown>;
|
||||
const curSoft = (s.soft_preferences ?? {}) as Record<string, unknown>;
|
||||
const hard = merge(curHard, a.hard, b.hard);
|
||||
// 策略模板的分辨率范围是保留版本边界;统一预设只作用于隐式默认版本。
|
||||
const fromHard = { ...a.hard };
|
||||
const toHard = { ...b.hard };
|
||||
if (
|
||||
curHard.resolutions &&
|
||||
(s.policy_revision_id || slots.length > 1 || !sameJson(curHard.resolutions, a.hard.resolutions))
|
||||
) {
|
||||
delete fromHard.resolutions;
|
||||
delete toHard.resolutions;
|
||||
}
|
||||
const hard = merge(curHard, fromHard, toHard);
|
||||
const soft = merge(curSoft, a.soft, b.soft);
|
||||
if (sameJson(hard, curHard) && sameJson(soft, curSoft)) continue;
|
||||
await exec
|
||||
|
||||
@@ -22,15 +22,17 @@ import {
|
||||
type SeasonProgress,
|
||||
type SlotSpec,
|
||||
seasonProgress,
|
||||
seasonReleaseKey,
|
||||
seasonWildcard,
|
||||
selectSeasonRelease,
|
||||
type UnitRange,
|
||||
upgradeSpec,
|
||||
} from "@umt/domain";
|
||||
import { slotSpecFromRow } from "@umt/mapping";
|
||||
import { interceptHook } from "../../../hooks/index.ts";
|
||||
import { contextOf, parsedOf, unitKeysOf } from "../../sources-identity/service/release-facts.ts";
|
||||
import { errMessage, jsonb, toApi } from "../util.ts";
|
||||
import { type AoDeps, type Exec, upsertDecision } from "./common.ts";
|
||||
import { emitSubscriptionEvent } from "./lifecycle-events.ts";
|
||||
import { contextOf, parsedOf, unitKeysOf } from "./release-facts.ts";
|
||||
import { entityOriginalLanguage, loadRuleCatalog, mergeHard, type RuleCatalog, resolveRuleChain } from "./rules.ts";
|
||||
|
||||
// W5-C 订阅业务:季总集数账本、缺集/在途、完成判定与历史、Slot 重评调度、分集质量账本、主动搜索调度。
|
||||
@@ -212,9 +214,14 @@ export async function applyEpisodeTotal(
|
||||
/** Slot 已覆盖的内容单元:该 Slot 计划产出资产 + 当前资产 + 分集账本(content_unit id 与合成键两种口径) */
|
||||
export async function slotCoveredUnits(
|
||||
exec: Exec,
|
||||
slot: Pick<AcquisitionSlotRow, "id" | "current_asset_id" | "media_entity_id">,
|
||||
slot: Pick<AcquisitionSlotRow, "id" | "current_asset_id" | "media_entity_id"> &
|
||||
Partial<Pick<AcquisitionSlotRow, "hard_conditions">>,
|
||||
opts: { entityWide?: boolean } = {},
|
||||
): Promise<{ keys: Set<string>; episodes: Map<number, Set<number>> }> {
|
||||
const allowed = ((slot.hard_conditions ?? {}) as { resolutions?: string[] }).resolutions ?? [];
|
||||
const resolutionFilter = allowed.length
|
||||
? sql`and coalesce(a.media_info->'_umt'->'quality'->>'resolution', r.parsed->>'resolution') in (${sql.join(allowed)})`
|
||||
: sql``;
|
||||
const r = await sql<{
|
||||
unit_id: string;
|
||||
season_number: number | null;
|
||||
@@ -224,9 +231,10 @@ export async function slotCoveredUnits(
|
||||
}>`
|
||||
with assets as (
|
||||
select a.id from media_asset a
|
||||
left join source_release r on r.id = a.source_release_id
|
||||
left join download_task dt on dt.id = a.download_task_id
|
||||
left join acquisition_plan p on p.id = dt.acquisition_plan_id
|
||||
where a.status = 'active' and a.parent_asset_id is null
|
||||
where a.status = 'active' and a.parent_asset_id is null ${resolutionFilter}
|
||||
and (p.slot_id = ${slot.id} or a.id = ${slot.current_asset_id}
|
||||
or a.id in (select media_asset_id from slot_unit where slot_id = ${slot.id})
|
||||
${opts.entityWide ? sql`or a.media_entity_id = ${slot.media_entity_id}` : sql``})
|
||||
@@ -237,6 +245,7 @@ export async function slotCoveredUnits(
|
||||
join content_unit u on u.id = c.content_unit_id
|
||||
union
|
||||
select u.id, u.season_number, u.episode_number, u.kind, to_char(u.air_date, 'YYYYMMDD') from slot_unit su
|
||||
join assets on assets.id = su.media_asset_id
|
||||
join content_unit u on u.id = su.content_unit_id
|
||||
where su.slot_id = ${slot.id} and su.media_asset_id is not null`.execute(exec);
|
||||
const keys = new Set<string>();
|
||||
@@ -257,9 +266,10 @@ export async function slotCoveredUnits(
|
||||
const legacy = await sql<{ keys: string[] | null }>`
|
||||
select array(select jsonb_array_elements_text(coalesce(a.media_info->'_umt'->'unitKeys', '[]'::jsonb))) as keys
|
||||
from media_asset a
|
||||
left join source_release r on r.id = a.source_release_id
|
||||
left join download_task dt on dt.id = a.download_task_id
|
||||
left join acquisition_plan p on p.id = dt.acquisition_plan_id
|
||||
where a.status = 'active' and a.parent_asset_id is null
|
||||
where a.status = 'active' and a.parent_asset_id is null ${resolutionFilter}
|
||||
and (p.slot_id = ${slot.id} or a.id = ${slot.current_asset_id})`.execute(exec);
|
||||
for (const row of legacy.rows)
|
||||
for (const k of row.keys ?? []) {
|
||||
@@ -376,11 +386,13 @@ export function slotSpecWithRules(
|
||||
siteId?: string,
|
||||
): SlotSpec {
|
||||
const spec = slotSpecFromRow(toApi<AcquisitionSlot>(slot));
|
||||
if (entity.kind === "movie" && spec.upgrade?.consistency) spec.upgrade = { ...spec.upgrade, consistency: undefined };
|
||||
const baseHard = spec.hard;
|
||||
const chain = resolveRuleChain(cat, {
|
||||
kind: "acquisition",
|
||||
slotRuleGroupIds: (slot.rule_group_ids ?? []) as string[],
|
||||
subRuleGroupIds: subParams(sub).ruleGroupIds,
|
||||
upgrade: !!spec.upgrade?.enabled,
|
||||
upgrade: false,
|
||||
mediaKind: ruleMediaKind(entity.kind),
|
||||
entityId: entity.id,
|
||||
siteId,
|
||||
@@ -388,6 +400,22 @@ export function slotSpecWithRules(
|
||||
});
|
||||
spec.hard = mergeHard(spec.hard, chain.hard);
|
||||
if (chain.groups.length) spec.rules = { groups: chain.groups, atoms: chain.atoms };
|
||||
if (spec.upgrade?.enabled) {
|
||||
const washing = resolveRuleChain(cat, {
|
||||
kind: "acquisition",
|
||||
slotRuleGroupIds: spec.upgrade.ruleGroupIds?.length
|
||||
? spec.upgrade.ruleGroupIds
|
||||
: ((slot.rule_group_ids ?? []) as string[]),
|
||||
subRuleGroupIds: subParams(sub).ruleGroupIds,
|
||||
upgrade: !spec.upgrade.ruleGroupIds?.length,
|
||||
mediaKind: ruleMediaKind(entity.kind),
|
||||
entityId: entity.id,
|
||||
siteId,
|
||||
subscriptionId: sub?.id,
|
||||
});
|
||||
spec.upgradeHard = mergeHard(baseHard, washing.hard);
|
||||
spec.upgradeRules = { groups: washing.groups, atoms: washing.atoms };
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
@@ -396,8 +424,10 @@ export function slotSpecWithRules(
|
||||
* (同 evaluateCandidate 的 at_ceiling);按来源等级比较的升级没有「最高档」,返回 null(覆盖齐即完成)。
|
||||
*/
|
||||
export function upgradeTargetPri(spec: SlotSpec): number | null {
|
||||
spec = upgradeSpec(spec);
|
||||
const up = spec.upgrade;
|
||||
if (!up?.enabled) return null;
|
||||
if (up.consistency === "season_release") return up.targetPri ?? 100;
|
||||
const tiered = !!spec.soft?.tiers?.length || !!spec.rules?.groups.some((g) => g.layers.length > 0);
|
||||
return (up.basis ?? (tiered ? "priority" : "tier")) === "priority" ? (up.targetPri ?? 100) : null;
|
||||
}
|
||||
@@ -413,6 +443,8 @@ interface PriVersion {
|
||||
/** 覆盖的单元:季 → 集号集合;null = 整季(季包通配) */
|
||||
units: Map<number, Set<number> | null>;
|
||||
pri: number;
|
||||
releaseKey: string | null;
|
||||
quality: QualityFacts;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -441,13 +473,13 @@ async function upgradeReached(
|
||||
kind: sub?.media_kind ?? (input.mediaType === "movie" ? "movie" : "series"),
|
||||
};
|
||||
const lang = await entityOriginalLanguage(exec, slot.media_entity_id);
|
||||
const relPri = (rel: SourceReleaseRow) => {
|
||||
const spec = slotSpecWithRules(cat, slot, sub, entity, rel.site_id);
|
||||
const parsed = parsedOf(rel);
|
||||
const relPri = (rel: SourceReleaseRow, quality?: QualityFacts) => {
|
||||
const spec = upgradeSpec(slotSpecWithRules(cat, slot, sub, entity, rel.site_id));
|
||||
const parsed = { ...parsedOf(rel), ...quality };
|
||||
const ctx = { ...contextOf(rel), ...(lang ? { originalLanguage: lang } : {}) };
|
||||
return effectiveLadder(spec, parsed, ctx, ruleChainOf(spec, parsed, ctx)).pri;
|
||||
};
|
||||
const baseSpec = slotSpecWithRules(cat, slot, sub, entity);
|
||||
const baseSpec = upgradeSpec(slotSpecWithRules(cat, slot, sub, entity));
|
||||
const versions: PriVersion[] = [];
|
||||
const assetIds = [...new Set([...input.newAssets, ...(slot.current_asset_id ? [slot.current_asset_id] : [])])];
|
||||
const assets = await exec
|
||||
@@ -515,8 +547,17 @@ async function upgradeReached(
|
||||
: [];
|
||||
for (const x of assets) {
|
||||
const rel = x.source_release_id ? rels.get(x.source_release_id) : undefined;
|
||||
const quality = {
|
||||
...(rel ? parsedOf(rel) : {}),
|
||||
...((x.media_info as { _umt?: { quality?: QualityFacts } })?._umt?.quality ?? {}),
|
||||
};
|
||||
if (
|
||||
baseSpec.hard.resolutions?.length &&
|
||||
(!quality.resolution || !baseSpec.hard.resolutions.includes(quality.resolution))
|
||||
)
|
||||
continue;
|
||||
const pri =
|
||||
(rel ? relPri(rel) : undefined) ??
|
||||
(rel ? relPri(rel, quality) : undefined) ??
|
||||
evalPri(x.plan_evaluation) ??
|
||||
currentPri(
|
||||
baseSpec,
|
||||
@@ -530,12 +571,18 @@ async function upgradeReached(
|
||||
set.add(c.episode_number);
|
||||
units.set(s, set);
|
||||
}
|
||||
versions.push({ units, pri });
|
||||
versions.push({ units, pri, quality: { ...quality, pri }, releaseKey: seasonReleaseKey(quality) });
|
||||
}
|
||||
for (const p of plans) {
|
||||
const rel = p.source_release_id ? rels.get(p.source_release_id) : undefined;
|
||||
const pri = (rel ? relPri(rel) : undefined) ?? evalPri(p.evaluation);
|
||||
if (pri === undefined) continue;
|
||||
const quality: QualityFacts = rel ? parsedOf(rel) : {};
|
||||
if (
|
||||
pri === undefined ||
|
||||
(baseSpec.hard.resolutions?.length &&
|
||||
(!quality.resolution || !baseSpec.hard.resolutions.includes(quality.resolution)))
|
||||
)
|
||||
continue;
|
||||
const units = new Map<number, Set<number> | null>();
|
||||
for (const k of p.unit_keys) {
|
||||
const u = parseUnitKey(k);
|
||||
@@ -544,9 +591,33 @@ async function upgradeReached(
|
||||
else if (u.episode !== undefined && units.get(u.season) !== null)
|
||||
units.set(u.season, (units.get(u.season) ?? new Set<number>()).add(u.episode));
|
||||
}
|
||||
versions.push({ units, pri });
|
||||
versions.push({ units, pri, quality: { ...quality, pri }, releaseKey: seasonReleaseKey(quality) });
|
||||
}
|
||||
if (input.mediaType === "movie") return versions.some((v) => v.pri >= input.target);
|
||||
if (baseSpec.upgrade?.consistency === "season_release") {
|
||||
for (const [season, eps] of input.covered) {
|
||||
const targetKey = seasonReleaseKey(
|
||||
selectSeasonRelease(
|
||||
baseSpec,
|
||||
versions.filter((v) => v.units.has(season)).map((v) => v.quality),
|
||||
) ?? {},
|
||||
);
|
||||
if (
|
||||
!targetKey ||
|
||||
!eps.every((e) =>
|
||||
versions.some(
|
||||
(v) =>
|
||||
v.releaseKey === targetKey &&
|
||||
v.pri >= input.target &&
|
||||
v.units.has(season) &&
|
||||
(v.units.get(season) === null || v.units.get(season)!.has(e)),
|
||||
),
|
||||
)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
for (const [season, eps] of input.covered)
|
||||
for (const e of eps) {
|
||||
const best = Math.max(
|
||||
@@ -603,16 +674,21 @@ export async function slotComplete(
|
||||
opts: { includeSubmitted?: boolean } = {},
|
||||
): Promise<SlotCompletion> {
|
||||
const completion = (slot.completion ?? {}) as { kind?: string; cuts?: string[] };
|
||||
const accepted = opts.includeSubmitted
|
||||
const allowedResolutions = ((slot.hard_conditions ?? {}) as { resolutions?: string[] }).resolutions;
|
||||
const acceptedRows = opts.includeSubmitted
|
||||
? await exec
|
||||
.selectFrom("acquisition_plan as p")
|
||||
.innerJoin("source_release as r", "r.id", "p.source_release_id")
|
||||
.select(["p.id as plan_id", "p.unit_keys", "r.parsed", "r.title"])
|
||||
.selectAll("r")
|
||||
.select(["p.id as plan_id", "p.unit_keys"])
|
||||
.where("p.slot_id", "=", slot.id)
|
||||
.where("p.status", "in", ["submitted", "completed"])
|
||||
.where("p.status_reason", "!=", "accepted_unconfirmed")
|
||||
.execute()
|
||||
: [];
|
||||
const accepted = acceptedRows.filter(
|
||||
(r) => !allowedResolutions?.length || allowedResolutions.includes(parsedOf(r).resolution ?? ""),
|
||||
);
|
||||
const sub = slot.subscription_id
|
||||
? ((await exec.selectFrom("subscription").selectAll().where("id", "=", slot.subscription_id).executeTakeFirst()) ??
|
||||
null)
|
||||
@@ -735,7 +811,7 @@ export async function afterSlotVerified(
|
||||
const plan = input.planId
|
||||
? await trx
|
||||
.selectFrom("acquisition_plan")
|
||||
.select(["id", "purpose", "unit_keys"])
|
||||
.select(["id", "purpose", "unit_keys", "evaluation"])
|
||||
.where("id", "=", input.planId)
|
||||
.executeTakeFirst()
|
||||
: undefined;
|
||||
@@ -757,7 +833,11 @@ export async function afterSlotVerified(
|
||||
const planUnits = new Set(plan?.unit_keys ?? []);
|
||||
for (const r of cov.rows) {
|
||||
const q = qualityOf(r.media_info);
|
||||
const pri = priorityLadder(spec, { raw: "", hdr: [], isEncode: false, ...q } as unknown as ParsedRelease).pri;
|
||||
const plannedPri = (plan?.evaluation as { pri?: unknown })?.pri;
|
||||
const pri =
|
||||
typeof plannedPri === "number"
|
||||
? plannedPri
|
||||
: priorityLadder(spec, { raw: "", hdr: [], isEncode: false, ...q } as unknown as ParsedRelease).pri;
|
||||
const inPlan = planUnits.has(r.unit_id) || (r.ep !== null && planUnits.has(episodeKey(r.season ?? 1, r.ep)));
|
||||
const replace = plan?.purpose === "upgrade" ? inPlan : true;
|
||||
const values = {
|
||||
@@ -876,18 +956,6 @@ export async function completeSubscription(
|
||||
reason: string,
|
||||
progress: Record<string, unknown> = {},
|
||||
): Promise<boolean> {
|
||||
// 链式钩子 subscription.completion.check(ADR-006):追加完成条件,拒绝时订阅继续追踪
|
||||
const veto = await interceptHook(d.ctx, "subscription.completion.check", () => ({
|
||||
subscriptionId: sub.id,
|
||||
mediaEntityId: sub.media_entity_id,
|
||||
mediaKind: sub.media_kind,
|
||||
reason,
|
||||
progress,
|
||||
}));
|
||||
if (veto) {
|
||||
d.ctx.logger.info({ subscriptionId: sub.id, reason, veto }, "订阅完成被钩子拦下,继续追踪");
|
||||
return false;
|
||||
}
|
||||
const done = await trx
|
||||
.updateTable("subscription")
|
||||
.set({ status: "completed", status_reason: reason, next_search_at: null })
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { AcquisitionSlotRow } from "@umt/db";
|
||||
import { sql } from "@umt/db";
|
||||
import { episodeKey, type QualityFacts, unitsOverlap } from "@umt/domain";
|
||||
import type { Exec } from "./common.ts";
|
||||
|
||||
/** 自动洗版只选择本保留版本的旧文件;合集可以先冻结,完整覆盖后再回收。 */
|
||||
export async function automaticReplacementAssets(
|
||||
exec: Exec,
|
||||
slot: AcquisitionSlotRow,
|
||||
units: readonly string[],
|
||||
candidate: QualityFacts,
|
||||
): Promise<string[]> {
|
||||
const rows = await sql<{
|
||||
id: string;
|
||||
resolution: string | null;
|
||||
cut: string | null;
|
||||
season: number | null;
|
||||
episode: number | null;
|
||||
}>`
|
||||
select distinct a.id, coalesce(a.media_info->'_umt'->'quality'->>'resolution', r.parsed->>'resolution') as resolution,
|
||||
a.content_edition as cut, u.season_number as season, u.episode_number as episode
|
||||
from media_asset a
|
||||
left join source_release r on r.id = a.source_release_id
|
||||
left join download_task dt on dt.id = a.download_task_id
|
||||
left join acquisition_plan p on p.id = dt.acquisition_plan_id
|
||||
left join asset_unit_coverage c on c.media_asset_id = a.id and c.coverage = 'full'
|
||||
left join content_unit u on u.id = c.content_unit_id
|
||||
where a.media_entity_id = ${slot.media_entity_id} and a.status = 'active' and a.kind in ('video', 'bluray_dir')
|
||||
and a.parent_asset_id is null
|
||||
and (p.slot_id = ${slot.id} or a.id = ${slot.current_asset_id}
|
||||
or exists (select 1 from slot_unit where slot_id = ${slot.id} and media_asset_id = a.id)
|
||||
or (select count(*) from acquisition_slot where media_entity_id = ${slot.media_entity_id}) = 1)
|
||||
and exists (select 1 from asset_location l join library_root lr on lr.id = l.library_root_id
|
||||
where l.media_asset_id = a.id and l.location_kind = 'library' and l.status = 'available' and lr.management_mode = 'managed')
|
||||
and not exists (select 1 from asset_location l join library_root lr on lr.id = l.library_root_id
|
||||
where l.media_asset_id = a.id and l.location_kind = 'library' and l.status = 'available' and lr.management_mode <> 'managed')`.execute(
|
||||
exec,
|
||||
);
|
||||
const allowed = ((slot.hard_conditions ?? {}) as { resolutions?: string[] }).resolutions;
|
||||
const selected = new Set<string>();
|
||||
for (const r of rows.rows) {
|
||||
if (allowed?.length && (!r.resolution || !allowed.includes(r.resolution))) continue;
|
||||
if (!units.length) {
|
||||
if ((r.cut ?? "default") === (candidate.cut ?? "default")) selected.add(r.id);
|
||||
} else if (r.season !== null && r.episode !== null && unitsOverlap(units, [episodeKey(r.season, r.episode)]).length)
|
||||
selected.add(r.id);
|
||||
}
|
||||
return [...selected];
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AuthorizationChecker, AuthorizationDecision } from "@umt/agent";
|
||||
import type { CapabilitySpec } from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import { APPROVAL_PLAN, coveringGrant, type PlanBinding, planBinding } from "../../authorization/grants.ts";
|
||||
import type { SettingsReader } from "../types.ts";
|
||||
import { inlineApproval, optionApproval, proposeFirstBlock } from "./interaction.ts";
|
||||
import { investigationDecisionId } from "./runner.ts";
|
||||
@@ -15,172 +15,9 @@ import { exceedsAncestors } from "./subagents.ts";
|
||||
// 模型参数里的"用户已同意"不会被读取;输入里的 approvalId 也只是指针,必须在库里核对。
|
||||
// 控制台管理员从 Web 手动调用(source=web 且 actor=user:*)本身就是人工授权;API Key / Agent / 后台任务一律要授权记录。
|
||||
|
||||
/** 能力 → 可覆盖它的授权动作(策略发布生成 policy.<kind> 持续授权;"*" 全部) */
|
||||
export const GRANT_ACTIONS: Record<string, readonly string[]> = {
|
||||
"identity.resolve": ["identity.resolve", "policy.identity"],
|
||||
"download.submit": ["download.submit", "policy.acquisition"],
|
||||
"organize.execute": ["organize.execute", "policy.acquisition", "policy.organize"],
|
||||
"download.recheck": ["download.recheck", "policy.automation"],
|
||||
"task.retry": ["task.retry", "task.*"],
|
||||
"task.cancel": ["task.cancel", "task.*"],
|
||||
"policy.apply": ["policy.apply"],
|
||||
// 集数映射兜底(批次 5):只认专门的持续授权,不随身份识别策略放开
|
||||
"episode_mapping.apply_rule": ["episode_mapping.apply_rule"],
|
||||
// VIS-1:坐标操作与视觉子流程和 browser.interact 同一档(操作原站页面),同一条持续授权覆盖
|
||||
"browser.click_at": ["browser.click_at", "browser.interact"],
|
||||
"browser.type_at": ["browser.type_at", "browser.interact"],
|
||||
"browser.scroll": ["browser.scroll", "browser.interact"],
|
||||
"browser.solve_challenge": ["browser.solve_challenge", "browser.interact"],
|
||||
};
|
||||
|
||||
/** 视觉子流程内由 solve_challenge 授权覆盖的执行类坐标动作 */
|
||||
const CHALLENGE_STEP_CAPS = new Set(["browser.click_at", "browser.type_at", "browser.scroll"]);
|
||||
|
||||
/** 单次审批绑定的计划类型与输入里的计划 ID 字段 */
|
||||
export const APPROVAL_PLAN: Record<string, { planType: string; field: string }> = {
|
||||
"download.submit": { planType: "acquisition_plan", field: "planId" },
|
||||
"organize.execute": { planType: "organize_plan", field: "planId" },
|
||||
"policy.apply": { planType: "policy_revision", field: "policyRevisionId" },
|
||||
};
|
||||
|
||||
export interface PlanBinding {
|
||||
payloadHash: string;
|
||||
revision: number;
|
||||
mediaEntityId: string | null;
|
||||
mediaKind: string | null;
|
||||
bytes: number | null;
|
||||
}
|
||||
|
||||
const sha = (s: string) => createHash("sha256").update(s, "utf8").digest("hex");
|
||||
|
||||
/** 读计划当前的 payload hash / revision / 目标作品(审批绑定与范围校验共用) */
|
||||
export async function planBinding(db: Kysely<DB>, planType: string, planId: string): Promise<PlanBinding | null> {
|
||||
if (planType === "acquisition_plan") {
|
||||
const r = await db
|
||||
.selectFrom("acquisition_plan as p")
|
||||
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
|
||||
.leftJoin("media_entity as e", (j) =>
|
||||
j.onRef("e.id", "=", sql<string>`coalesce(s.media_entity_id, (p.evaluation->>'mediaEntityId')::uuid)`),
|
||||
)
|
||||
.select(["p.payload_hash", "p.expected_revision", "e.id as entity_id", "e.kind", "p.reserved_bytes"])
|
||||
.where("p.id", "=", planId)
|
||||
.executeTakeFirst();
|
||||
return r
|
||||
? {
|
||||
payloadHash: r.payload_hash,
|
||||
revision: r.expected_revision,
|
||||
mediaEntityId: r.entity_id,
|
||||
mediaKind: r.kind,
|
||||
bytes: Number(r.reserved_bytes),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if (planType === "organize_plan") {
|
||||
const r = await db
|
||||
.selectFrom("artifact")
|
||||
.select(["content_hash", "content"])
|
||||
.where("id", "=", planId)
|
||||
.where("kind", "=", "plan")
|
||||
.executeTakeFirst();
|
||||
if (!r) return null;
|
||||
const c = (r.content ?? {}) as { mediaEntityId?: string | null; mediaKind?: string | null };
|
||||
return {
|
||||
payloadHash: r.content_hash,
|
||||
revision: 1,
|
||||
mediaEntityId: c.mediaEntityId ?? null,
|
||||
mediaKind: c.mediaKind ?? null,
|
||||
bytes: null,
|
||||
};
|
||||
}
|
||||
if (planType === "policy_revision") {
|
||||
const r = await db
|
||||
.selectFrom("policy_revision")
|
||||
.select(["revision", "rules", "scope"])
|
||||
.where("id", "=", planId)
|
||||
.executeTakeFirst();
|
||||
return r
|
||||
? {
|
||||
payloadHash: sha(JSON.stringify({ rules: r.rules, scope: r.scope })),
|
||||
revision: r.revision,
|
||||
mediaEntityId: null,
|
||||
mediaKind: null,
|
||||
bytes: null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 持续授权是否覆盖本能力(不含单次审批与会话内审批):action 覆盖、active 未过期、绑定策略仍发布、
|
||||
* 对象范围与每日预算。download.plan 用它判定 requiresApproval,authorize 用它放行。
|
||||
*/
|
||||
export async function coveringGrant(
|
||||
db: Kysely<DB>,
|
||||
capabilityId: string,
|
||||
target: PlanBinding | null,
|
||||
now: Date,
|
||||
): Promise<{ grantId: string } | { reasons: string[] }> {
|
||||
const d = { id: capabilityId };
|
||||
const actions = [...(GRANT_ACTIONS[d.id] ?? [d.id]), "*"];
|
||||
const grants = await db
|
||||
.selectFrom("automation_grant as g")
|
||||
.leftJoin("policy_revision as p", "p.id", "g.policy_revision_id")
|
||||
.select(["g.id", "g.action", "g.scope", "g.budget", "g.policy_revision_id", "p.status as policy_status"])
|
||||
.where("g.status", "=", "active")
|
||||
.where("g.action", "in", actions)
|
||||
.where((eb) => eb.or([eb("g.expires_at", "is", null), eb("g.expires_at", ">", now)]))
|
||||
.orderBy("g.created_at", "desc")
|
||||
.execute();
|
||||
const reasons: string[] = [];
|
||||
for (const g of grants) {
|
||||
if (g.policy_revision_id && g.policy_status !== "published") {
|
||||
reasons.push(`授权 ${g.id} 绑定的策略版本已不是发布状态`);
|
||||
continue;
|
||||
}
|
||||
const scope = (g.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[]; capabilities?: string[] };
|
||||
if (scope.capabilities?.length && !scope.capabilities.includes(d.id)) {
|
||||
reasons.push(`授权 ${g.id} 不含能力 ${d.id}`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
scope.mediaEntityIds?.length &&
|
||||
(!target?.mediaEntityId || !scope.mediaEntityIds.includes(target.mediaEntityId))
|
||||
) {
|
||||
reasons.push(`授权 ${g.id} 的对象范围不含目标作品`);
|
||||
continue;
|
||||
}
|
||||
if (scope.mediaKinds?.length && target?.mediaKind && !kindCovered(scope.mediaKinds, target.mediaKind)) {
|
||||
reasons.push(`授权 ${g.id} 的媒体类型范围不含 ${target.mediaKind}`);
|
||||
continue;
|
||||
}
|
||||
const budget = (g.budget ?? {}) as { maxInvocationsPerDay?: number; maxBytesPerDay?: number };
|
||||
if (budget.maxInvocationsPerDay !== undefined || budget.maxBytesPerDay !== undefined) {
|
||||
const day = new Date(now);
|
||||
day.setHours(0, 0, 0, 0);
|
||||
const used = await sql<{ n: number }>`
|
||||
select count(*)::int as n from capability_invocation
|
||||
where grant_id = ${g.id} and status = 'succeeded' and created_at >= ${day}`.execute(db);
|
||||
if (budget.maxInvocationsPerDay !== undefined && (used.rows[0]?.n ?? 0) >= budget.maxInvocationsPerDay) {
|
||||
reasons.push(`授权 ${g.id} 今日次数预算已用尽`);
|
||||
continue;
|
||||
}
|
||||
if (budget.maxBytesPerDay !== undefined && d.id === "download.submit") {
|
||||
const bytes = await sql<{ b: string }>`
|
||||
select coalesce(sum(p.reserved_bytes), 0)::bigint as b
|
||||
from capability_invocation ci join acquisition_plan p on p.id::text = ci.input->>'planId'
|
||||
where ci.grant_id = ${g.id} and ci.status = 'succeeded' and ci.created_at >= ${day}`.execute(db);
|
||||
if (Number(bytes.rows[0]?.b ?? 0) + (target?.bytes ?? 0) > budget.maxBytesPerDay) {
|
||||
reasons.push(`授权 ${g.id} 今日字节预算不足`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { grantId: g.id };
|
||||
}
|
||||
return { reasons };
|
||||
}
|
||||
|
||||
export function createAuthorizer(deps: {
|
||||
db: Kysely<DB>;
|
||||
settings: SettingsReader;
|
||||
@@ -284,6 +121,16 @@ export function createAuthorizer(deps: {
|
||||
// 集数映射:持续授权的对象范围按事项的作品核对
|
||||
if (d.id === "episode_mapping.apply_rule" && typeof inp.decisionId === "string")
|
||||
target = await episodeTarget(db, inp.decisionId);
|
||||
if (d.id === "release.apply_facts" && typeof inp.sourceReleaseId === "string") {
|
||||
const release = await db
|
||||
.selectFrom("source_release as r")
|
||||
.innerJoin("media_entity as e", "e.id", "r.media_entity_id")
|
||||
.select(["e.id", "e.kind"])
|
||||
.where("r.id", "=", inp.sourceReleaseId)
|
||||
.executeTakeFirst();
|
||||
if (!release) return { allowed: false, reason: "资源尚未关联作品,无法核对属性补全授权范围" };
|
||||
target = { payloadHash: "", revision: 0, mediaEntityId: release.id, mediaKind: release.kind, bytes: null };
|
||||
}
|
||||
|
||||
// 2) 持续授权
|
||||
const covered = await coveringGrant(db, d.id, target, now());
|
||||
@@ -330,10 +177,3 @@ async function episodeTarget(db: Kysely<DB>, decisionId: string): Promise<PlanBi
|
||||
}
|
||||
|
||||
const isUuid = (s: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
|
||||
|
||||
/** 策略范围用订阅口径(movie / series / variety),作品用 media_entity.kind(movie / tv / variety …) */
|
||||
function kindCovered(kinds: readonly string[], entityKind: string): boolean {
|
||||
if (kinds.includes(entityKind)) return true;
|
||||
if (entityKind !== "movie" && entityKind !== "variety" && kinds.includes("series")) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
|
||||
import { capabilitySpec, type DownloadStatus } from "@umt/contracts";
|
||||
import { planBinding } from "../../authorization/grants.ts";
|
||||
import { downloaderClientFor } from "../acquisition-organize/ports.ts";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
import { planBinding } from "./authorize.ts";
|
||||
import type { AgentModuleOptions } from "./runner.ts";
|
||||
|
||||
// download.recheck(REQ §8.4.2 / §10.7 授权内恢复):对已纳管的下载任务发起 qB 强制重新校验或恢复暂停。
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface MetadataCapabilityOptions {
|
||||
/** 模型调用(测试注入 fake);返回 null = 按规则抽取 */
|
||||
complete?: EnrichDeps["complete"];
|
||||
/** 读 robots.txt 用的 fetch / DNS 解析(测试注入 mock) */
|
||||
fetch?: typeof fetch;
|
||||
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
resolveHost?: HostResolver;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,11 @@ const ROBOTS_TTL_SEC = 86400;
|
||||
export async function robotsAllowedFor(
|
||||
ctx: Pick<ServerContext, "db">,
|
||||
url: string,
|
||||
opts: { fetch?: typeof fetch; resolveHost?: HostResolver; signal?: AbortSignal } = {},
|
||||
opts: {
|
||||
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
resolveHost?: HostResolver;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<boolean> {
|
||||
let origin: string;
|
||||
try {
|
||||
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
type KnownCapabilityId,
|
||||
} from "@umt/contracts";
|
||||
import { type AgentRunRow, type DB, type Kysely, sql } from "@umt/db";
|
||||
import { APPROVAL_PLAN, type PlanBinding, planBinding } from "../../authorization/grants.ts";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
import { APPROVAL_PLAN, type PlanBinding, planBinding } from "./authorize.ts";
|
||||
import {
|
||||
AwaitSuspendedError,
|
||||
type AwaitSuspension,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { DecisionOption, DownloadStatus } from "@umt/contracts";
|
||||
import { type DecisionRow, sql } from "@umt/db";
|
||||
import { GRANT_ACTIONS } from "../../authorization/grants.ts";
|
||||
import { missingReleaseFields } from "../sources-identity/service/release-facts-admin.ts";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
import { GRANT_ACTIONS } from "./authorize.ts";
|
||||
import {
|
||||
type DriveResult,
|
||||
INVESTIGATE_CAPABILITIES,
|
||||
@@ -138,6 +139,8 @@ async function relatedTask(ctx: ServerContext, d: DecisionRow) {
|
||||
}
|
||||
|
||||
function suggestionFor(d: DecisionRow, reason: string): string {
|
||||
if (d.dedupe_key.startsWith("release_facts:"))
|
||||
return `${reason}。可查看资源详情补齐属性,或为 release.apply_facts 设置持续授权。`;
|
||||
if (d.dedupe_key.startsWith("organize_identity:")) return `${reason}。可在整理记录里指定作品或重新整理。`;
|
||||
if (d.kind === "identity_ambiguous")
|
||||
return `${reason}。可在媒体库「未识别」里手动指定作品,或为 identity.resolve 发放持续授权。`;
|
||||
@@ -183,7 +186,8 @@ export async function autoInvestigate(
|
||||
const d = await ctx.db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst();
|
||||
if (d?.dedupe_key.startsWith("organize_identity:")) return identifyOrganize(ctx, d, opts.manual === true);
|
||||
if (d?.status !== "open") return { runId: null, created: false };
|
||||
if (!(KINDS as readonly string[]).includes(d.kind)) return { runId: null, created: false };
|
||||
const releaseFacts = d.dedupe_key.startsWith("release_facts:");
|
||||
if (!(KINDS as readonly string[]).includes(d.kind) && !releaseFacts) return { runId: null, created: false };
|
||||
// Agent 自己引起的事项不再触发 Agent(防止递归自触发)
|
||||
if (d.subject_type === "agent_run") return { runId: null, created: false };
|
||||
const s = await ctx.settings.get("agent");
|
||||
@@ -228,10 +232,12 @@ export async function autoInvestigate(
|
||||
);
|
||||
return { runId: null, created: false, skipped: "attempts_exhausted" };
|
||||
}
|
||||
const grants = await recoveryGrants(ctx, ACTIONS_FOR[d.kind] ?? []);
|
||||
const grants = await recoveryGrants(ctx, releaseFacts ? ["release.apply_facts"] : (ACTIONS_FOR[d.kind] ?? []));
|
||||
if (grants.length === 0) {
|
||||
mode = "investigate";
|
||||
degraded = { reason: `没有覆盖 ${(ACTIONS_FOR[d.kind] ?? []).join(" / ")} 的有效持续授权` };
|
||||
degraded = {
|
||||
reason: `没有覆盖 ${(releaseFacts ? ["release.apply_facts"] : (ACTIONS_FOR[d.kind] ?? [])).join(" / ")} 的有效持续授权`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,23 +258,41 @@ export async function autoInvestigate(
|
||||
d.subject_type && d.subject_id ? `对象:${d.subject_type} ${d.subject_id}。` : "",
|
||||
dt ? `下载任务 ${dt.id}「${dt.name ?? ""}」当前 ${dt.status}(qB 状态 ${dt.raw_state ?? "未知"})。` : "",
|
||||
taskId ? `关联任务 ${taskId}。` : "",
|
||||
ep
|
||||
? `作品 ${String(ep.mediaEntityId ?? d.subject_id ?? "")},来源季 ${ep.season ?? "未知"},发布组 ${ep.releaseGroup ?? "无"}。` +
|
||||
releaseFacts
|
||||
? `资源 ${d.subject_id},缺失属性 ${JSON.stringify((d.evidence as { missing?: string[] })?.missing ?? [])}。先用 release.inspect(refreshDetail:true) 看详情、MediaInfo 和文件证据;` +
|
||||
(mode === "recover"
|
||||
? "先用 episode_mapping.inspect(decisionId)看上下文,再用 episode_mapping.simulate 试一行作品识别词(如 S02 @发布组 >> S01 EP+12);" +
|
||||
"模拟里事项样本全部解决、且没有让已对上的资源变成待确认时,才用 episode_mapping.apply_rule 把这一行写进作品识别词一次(带理由);" +
|
||||
"拿不准就不写,只把模拟通过的那一行留作建议。不逐集处理,是否解决由系统核实。"
|
||||
: "只做只读调查:用 episode_mapping.inspect(decisionId)看上下文,用 episode_mapping.simulate(带 decisionId 和理由)试一行作品识别词;" +
|
||||
"模拟通过的那一行会作为智能体建议留在事项上,由用户确认;不写规则,拿不准就只说明原因。")
|
||||
: mode === "recover"
|
||||
? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" +
|
||||
"task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。"
|
||||
: "只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。",
|
||||
? `有证据时用 release.apply_facts(decisionId:${d.id},sourceReleaseId:${d.subject_id}) 只补缺失事实,原规则自动重评;`
|
||||
: "只调查并给出有出处的补全建议;") +
|
||||
"不修改收录范围、保留目标或组合优先级,不直接选资源替代规则判断。"
|
||||
: ep
|
||||
? `作品 ${String(ep.mediaEntityId ?? d.subject_id ?? "")},来源季 ${ep.season ?? "未知"},发布组 ${ep.releaseGroup ?? "无"}。` +
|
||||
(mode === "recover"
|
||||
? "先用 episode_mapping.inspect(decisionId)看上下文,再用 episode_mapping.simulate 试一行作品识别词(如 S02 @发布组 >> S01 EP+12);" +
|
||||
"模拟里事项样本全部解决、且没有让已对上的资源变成待确认时,才用 episode_mapping.apply_rule 把这一行写进作品识别词一次(带理由);" +
|
||||
"拿不准就不写,只把模拟通过的那一行留作建议。不逐集处理,是否解决由系统核实。"
|
||||
: "只做只读调查:用 episode_mapping.inspect(decisionId)看上下文,用 episode_mapping.simulate(带 decisionId 和理由)试一行作品识别词;" +
|
||||
"模拟通过的那一行会作为智能体建议留在事项上,由用户确认;不写规则,拿不准就只说明原因。")
|
||||
: mode === "recover"
|
||||
? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" +
|
||||
"task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。"
|
||||
: "只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。",
|
||||
].join("");
|
||||
const { run, created } = await startRun(ctx, {
|
||||
goal,
|
||||
mode: mode === "recover" ? "execute" : "plan_only",
|
||||
capabilityAllowlist: mode === "recover" ? [...RECOVER_CAPABILITIES] : [...INVESTIGATE_CAPABILITIES],
|
||||
capabilityAllowlist: releaseFacts
|
||||
? [
|
||||
"release.inspect",
|
||||
"catalog.lookup",
|
||||
"inventory.query",
|
||||
"decision.list",
|
||||
"web.search",
|
||||
"web.fetch",
|
||||
...(mode === "recover" ? ["release.apply_facts"] : []),
|
||||
]
|
||||
: mode === "recover"
|
||||
? [...RECOVER_CAPABILITIES]
|
||||
: [...INVESTIGATE_CAPABILITIES],
|
||||
trigger: "event",
|
||||
createdBy: mode === "recover" ? RECOVER_ACTOR : AUTO_ACTOR,
|
||||
dedupeKey: `investigate:${d.id}`,
|
||||
@@ -460,6 +484,23 @@ export async function checkRecovery(ctx: ServerContext, actions: RecoveryAction[
|
||||
: undefined;
|
||||
facts[`source_release:${id}`] = r ? { resolutionStatus: r.resolution_status } : null;
|
||||
states.push(r?.resolution_status === "resolved" && r.media_entity_id ? "recovered" : "pending");
|
||||
} else if (a.capabilityId === "release.apply_facts") {
|
||||
const id = String(a.input.decisionId ?? "");
|
||||
const decision = await ctx.db.selectFrom("decision").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
const release = decision?.subject_id
|
||||
? await ctx.db.selectFrom("source_release").selectAll().where("id", "=", decision.subject_id).executeTakeFirst()
|
||||
: undefined;
|
||||
const remaining = release
|
||||
? missingReleaseFields(release, (decision?.evidence as { missing?: string[] })?.missing ?? [])
|
||||
: [];
|
||||
facts[`decision:${id}`] = { status: decision?.status ?? null, remaining, releaseId: release?.id ?? null };
|
||||
states.push(
|
||||
!decision || !release
|
||||
? "failed"
|
||||
: decision.status === "resolved" && remaining.length === 0
|
||||
? "recovered"
|
||||
: "pending",
|
||||
);
|
||||
} else if (a.capabilityId === "organize.assign_identity") {
|
||||
// 指定作品后事项随即关闭、重新整理已入队;事项关闭即视为恢复(整理结果看整理记录)
|
||||
const id = String(a.input.decisionId ?? "");
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
type KnownCapabilityId,
|
||||
} from "@umt/contracts";
|
||||
import { type AgentRunRow, sql } from "@umt/db";
|
||||
import { planBinding } from "../../authorization/grants.ts";
|
||||
import { hasScope } from "../../http/auth.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import { planManualAcquisition } from "../acquisition-organize/services/manual-acquisition.ts";
|
||||
import { InternalDispatcher } from "../search/dispatch.ts";
|
||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||
import { planBinding } from "./authorize.ts";
|
||||
import { cancelTask, retryTask, taskToApi } from "./capabilities.ts";
|
||||
import { route } from "./http.ts";
|
||||
import { afterApprovalDecided, answerOptions } from "./interaction.ts";
|
||||
|
||||
@@ -91,6 +91,7 @@ export const RECOVER_CAPABILITIES = [
|
||||
"task.retry",
|
||||
"download.recheck",
|
||||
"episode_mapping.apply_rule",
|
||||
"release.apply_facts",
|
||||
] as const;
|
||||
/**
|
||||
* 整理没识别出作品时的智能体识别:查作品(TMDB / 媒体库 / 网页)+ 指定作品并重新整理。
|
||||
@@ -116,6 +117,7 @@ export function investigationDecisionId(dedupeKey: string | null | undefined): s
|
||||
}
|
||||
/** 被视为"恢复动作"的能力(结果需系统核实) */
|
||||
export const RECOVERY_ACTIONS = [
|
||||
"release.apply_facts",
|
||||
"task.retry",
|
||||
"download.recheck",
|
||||
"identity.resolve",
|
||||
@@ -158,10 +160,7 @@ export function scopeFor(mode: "execute" | "plan_only", allowlist?: readonly str
|
||||
*/
|
||||
export function sessionDirOf(env: AgentEnv, configured?: string | null): string {
|
||||
return resolve(
|
||||
env.opts.sessionDir ??
|
||||
configured ??
|
||||
Deno.env.get("AGENT_SESSION_DIR") ??
|
||||
join(Deno.cwd(), "data", "agent-sessions"),
|
||||
env.opts.sessionDir ?? configured ?? process.env.AGENT_SESSION_DIR ?? join(process.cwd(), "data", "agent-sessions"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -266,7 +266,11 @@ export async function executeResolution(ctx: ServerContext, decisionId: string)
|
||||
export const agentOutboxRoutes: Record<string, OutboxRouteFn> = {
|
||||
"decision.created": (ev) => {
|
||||
const p = ev.payload as EventPayload<"decision.created">;
|
||||
if (!["identity_ambiguous", "download_failed", "download_stalled", "episode_mapping"].includes(p.kind)) return null;
|
||||
if (
|
||||
!["identity_ambiguous", "download_failed", "download_stalled", "episode_mapping"].includes(p.kind) &&
|
||||
!p.dedupeKey?.startsWith("release_facts:")
|
||||
)
|
||||
return null;
|
||||
return { task: "resolveDecision", input: { decisionId: p.decisionId, trigger: "created" } };
|
||||
},
|
||||
"decision.updated": (ev) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runtimeUnrefTimer } from "@umt/runtime";
|
||||
// AG-4 浏览器会话保活:camofox 的标签页空闲 TAB_INACTIVITY_MS(缺省 5 分钟)没有 API 调用就回收,
|
||||
// 会话空闲 SESSION_TIMEOUT_MS 回收,全部会话没了之后 BROWSER_IDLE_TIMEOUT_MS 退出浏览器。
|
||||
// 桌面模式里人的键鼠直接进 Selkies,不经 API;原站视图的截图轮询也不算标签页活动——接管期间页面会被关掉。
|
||||
@@ -151,7 +152,7 @@ export class BrowserKeepalive {
|
||||
this.timer = setInterval(() => void this.tick(), this.opts.intervalMs);
|
||||
// 不拖住进程退出(测试、优雅停机)
|
||||
try {
|
||||
Deno.unrefTimer(this.timer as unknown as number);
|
||||
runtimeUnrefTimer(this.timer as unknown as number);
|
||||
} catch {
|
||||
/* 非 Deno 运行时 */
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export type Actor = {
|
||||
|
||||
export interface BrowserModuleOptions {
|
||||
/** camofox 请求用的 fetch(测试指向 mock) */
|
||||
fetch?: typeof globalThis.fetch;
|
||||
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
/** 站点限流器所在的运行时(与快照/实时搜索共享) */
|
||||
runtime?: (ctx: ServerContext) => SourcesRuntime;
|
||||
now?: () => Date;
|
||||
@@ -122,7 +122,7 @@ export interface BrowserModuleOptions {
|
||||
export interface ProviderDeps {
|
||||
secrets: BrowserSecretResolver;
|
||||
sink: DownloadSink;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
downloadPollMs?: number;
|
||||
}
|
||||
|
||||
|
||||
+26
-11
@@ -1,7 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { dirname, extname, join } from "node:path";
|
||||
import { TMDB_IMAGE_SIZES } from "@umt/contracts";
|
||||
import { externalImageReferer, TMDB_IMAGE_SIZES } from "@umt/contracts";
|
||||
|
||||
// TMDB 图片代理与文件缓存,移植 MP app/application/image.py(ImageHelper)+ api/endpoints/system.py(fetch_image):
|
||||
// - 缓存路径 = SecurityUtils.sanitize_url_path(url):URL 路径去掉开头 "/" 后百分号编码,超过 120 字符换成
|
||||
@@ -63,14 +63,14 @@ interface ImageFile {
|
||||
export interface ImageCacheOptions {
|
||||
/** DATA_DIR/cache/images */
|
||||
dir: string;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
timeoutMs?: number;
|
||||
/** 上游基址(测试指向 mock);缺省 https://image.tmdb.org/t/p */
|
||||
upstreamBase?: string;
|
||||
}
|
||||
|
||||
export class ImageCache {
|
||||
private readonly fetchImpl: typeof globalThis.fetch;
|
||||
private readonly fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
constructor(private readonly opts: ImageCacheOptions) {
|
||||
this.fetchImpl = opts.fetch ?? globalThis.fetch;
|
||||
@@ -94,8 +94,18 @@ export class ImageCache {
|
||||
|
||||
/** fetch_image:取图(可读缓存)→ ETag / 304 */
|
||||
async serve(size: string, file: string, useCache: boolean, ifNoneMatch: string | null): Promise<Response> {
|
||||
const url = this.upstream(size, file);
|
||||
const { content, mime } = await this.fetchImage(url, useCache);
|
||||
return this.respond(await this.fetchImage(this.upstream(size, file), useCache), ifNoneMatch);
|
||||
}
|
||||
|
||||
/** 豆瓣 / Bangumi 封面(白名单域名):带来源站 Referer 取图,缓存按域名分目录 */
|
||||
async serveExternal(url: string, useCache: boolean, ifNoneMatch: string | null): Promise<Response> {
|
||||
const referer = externalImageReferer(url);
|
||||
if (!referer) throw new ImageProxyError(400, "只代理豆瓣与 Bangumi 的图片");
|
||||
const host = new URL(url).hostname.toLowerCase();
|
||||
return this.respond(await this.fetchImage(url, useCache, { referer, cacheDir: join("ext", host) }), ifNoneMatch);
|
||||
}
|
||||
|
||||
private respond({ content, mime }: { content: Uint8Array; mime: string }, ifNoneMatch: string | null): Response {
|
||||
const etag = createHash("md5").update(content).digest("hex");
|
||||
const headers = new Headers({
|
||||
etag,
|
||||
@@ -116,8 +126,13 @@ export class ImageCache {
|
||||
}
|
||||
|
||||
/** ImageHelper.fetch_image_with_mime_type */
|
||||
private async fetchImage(url: string, useCache: boolean): Promise<{ content: Uint8Array; mime: string }> {
|
||||
const path = join(this.opts.dir, imageCachePath(url));
|
||||
private async fetchImage(
|
||||
url: string,
|
||||
useCache: boolean,
|
||||
ext: { referer: string; cacheDir: string } | null = null,
|
||||
): Promise<{ content: Uint8Array; mime: string }> {
|
||||
const path = join(this.opts.dir, ext?.cacheDir ?? "", imageCachePath(url));
|
||||
const what = ext ? "图片" : "TMDB 图片";
|
||||
if (useCache) {
|
||||
const cached = await fs.readFile(path).catch(() => null);
|
||||
const mime = cached?.length ? imageMimeType(cached) : null;
|
||||
@@ -130,19 +145,19 @@ export class ImageCache {
|
||||
try {
|
||||
res = await this.fetchImpl(url, {
|
||||
signal: AbortSignal.timeout(this.opts.timeoutMs ?? 20_000),
|
||||
headers: { accept: "image/avif,image/webp,image/apng,*/*" },
|
||||
headers: { accept: "image/avif,image/webp,image/apng,*/*", ...(ext ? { referer: ext.referer } : {}) },
|
||||
});
|
||||
} catch (e) {
|
||||
const reason = (e as Error).name === "TimeoutError" ? "超时" : (e as Error).message;
|
||||
throw new ImageProxyError(502, `拉取 TMDB 图片失败:${reason}`);
|
||||
throw new ImageProxyError(502, `拉取${what}失败:${reason}`);
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new ImageProxyError(502, `TMDB 图片返回 HTTP ${res.status}`);
|
||||
throw new ImageProxyError(502, `${what}返回 HTTP ${res.status}`);
|
||||
}
|
||||
const content = new Uint8Array(await res.arrayBuffer());
|
||||
const mime = imageMimeType(content);
|
||||
if (!mime) throw new ImageProxyError(502, "TMDB 返回的不是图片");
|
||||
if (!mime) throw new ImageProxyError(502, `${what}地址返回的不是图片`);
|
||||
await fs.mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmp, content);
|
||||
|
||||
+13
-1
@@ -38,7 +38,7 @@ import { ImageCache, ImageProxyError } from "./images.ts";
|
||||
*/
|
||||
export interface CacheModuleOptions {
|
||||
/** 图片上游 fetch(测试注入) */
|
||||
imageFetch?: typeof globalThis.fetch;
|
||||
imageFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
/** 图片上游基址(测试指向 mock) */
|
||||
imageUpstreamBase?: string;
|
||||
/** 图片缓存目录;缺省 DATA_DIR/cache/images */
|
||||
@@ -130,6 +130,18 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
|
||||
}
|
||||
});
|
||||
|
||||
// 豆瓣 / Bangumi 封面:浏览器直连豆瓣图床拿不到(不带 Referer 回 418),经这里带 Referer 取;总是读写磁盘缓存
|
||||
app.get("/img/ext", requireScope("read"), async (c) => {
|
||||
try {
|
||||
return await images(ctx).serveExternal(c.req.query("u") ?? "", true, c.req.header("if-none-match") ?? null);
|
||||
} catch (e) {
|
||||
if (e instanceof ImageProxyError) {
|
||||
return c.json(errorBody(e.status === 400 ? "bad_request" : "upstream_unavailable", e.message), e.status);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
route(app, Api.cache.tmdb, async ({ query }) => {
|
||||
const cache = sharedCache(ctx);
|
||||
const policy = await tmdbCachePolicy(ctx);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import type { DB, Kysely } from "@umt/db";
|
||||
import { upgradeWebSocket } from "@umt/runtime";
|
||||
import type { Context } from "hono";
|
||||
import type { AuthEnv } from "../../http/auth.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
@@ -61,7 +62,7 @@ export interface DesktopDeps {
|
||||
checkSession(sessionId: string, holder: string): Promise<SessionCheck>;
|
||||
/** 控制台会话是否仍有效(WS 长连接期间复查) */
|
||||
consoleSessionValid(sessionRowId: string): Promise<boolean>;
|
||||
fetch?: typeof fetch;
|
||||
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
/** 角色复查间隔 */
|
||||
roleCheckMs?: number;
|
||||
/** 桌面连接建立(AG-4):返回连接断开时调用的函数。用于连接期间保活浏览器会话 */
|
||||
@@ -325,11 +326,10 @@ function bridgeWebSocket(req: Request, target: string, role: DesktopRole, o: Bri
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const { socket: client, response } = Deno.upgradeWebSocket(req, protocols[0] ? { protocol: protocols[0] } : {});
|
||||
const { socket: client, response } = upgradeWebSocket(req, protocols[0] ? { protocol: protocols[0] } : {});
|
||||
const onClose = o.onOpen?.();
|
||||
client.binaryType = "arraybuffer";
|
||||
// Deno 的客户端 WebSocket 支持自定义请求头;不带 Origin(Selkies 对无 Origin 的非浏览器客户端放行)
|
||||
// (tsc 按 DOM 类型只认 protocols 参数;Deno 运行时接受 { headers, protocols })
|
||||
// Bun 的客户端 WebSocket 支持自定义认证头;不带 Origin(Selkies 对无 Origin 的客户端放行)
|
||||
const wsOptions = { headers: { authorization: o.auth(role) }, ...(protocols.length ? { protocols } : {}) };
|
||||
const upstream = new WebSocket(target, wsOptions as unknown as string[]);
|
||||
upstream.binaryType = "arraybuffer";
|
||||
|
||||
@@ -17,7 +17,7 @@ export function createDesktopModule(): ServerModule {
|
||||
const r = registerDesktopRoutes(app, {
|
||||
upstream: desktopUpstream({
|
||||
providerUrl: ctx.config?.BROWSER_PROVIDER_URL,
|
||||
desktopUrl: Deno.env.get("BROWSER_DESKTOP_URL") || undefined,
|
||||
desktopUrl: process.env.BROWSER_DESKTOP_URL || undefined,
|
||||
}),
|
||||
accessKey: ctx.config?.BROWSER_ACCESS_KEY ?? null,
|
||||
...dbSessionChecks(ctx.db),
|
||||
|
||||
@@ -54,7 +54,7 @@ export interface FmDeps {
|
||||
}
|
||||
|
||||
const envNum = (k: string, dflt: number) => {
|
||||
const v = Number(Deno.env.get(k));
|
||||
const v = Number(process.env[k]);
|
||||
return Number.isFinite(v) && v > 0 ? v : dflt;
|
||||
};
|
||||
|
||||
@@ -66,7 +66,7 @@ export function defaultOptions(ctx: ServerContext): FmOptions {
|
||||
trashCrossMaxBytes: envNum("FILES_TRASH_CROSS_MAX_BYTES", 2 * 1024 ** 3),
|
||||
planTtlMs: envNum("FILES_PLAN_TTL_SEC", 1800) * 1000,
|
||||
identifyMaxVideos: 200,
|
||||
allowPermanentDelete: Deno.env.get("FILES_ALLOW_PERMANENT_DELETE") === "true",
|
||||
allowPermanentDelete: process.env.FILES_ALLOW_PERMANENT_DELETE === "true",
|
||||
healthTimeoutMs: 2000,
|
||||
purgeIntervalMs: 24 * 3600_000,
|
||||
backgroundLoops: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { basename, extname } from "node:path";
|
||||
import type { FilesDownloadQuery } from "@umt/contracts";
|
||||
import { runtimeOpen } from "@umt/runtime";
|
||||
import type { Principal } from "../../http/auth.ts";
|
||||
import { remote, remoteError } from "../../storage/vfs.ts";
|
||||
import { FilesError, type FmDeps, isAdminSession, recheck, resolveIn } from "./common.ts";
|
||||
@@ -57,7 +58,7 @@ export async function downloadFile(
|
||||
throw new FilesError("forbidden", `只能下载 NFO、字幕、图片、文本和 .torrent,这个文件是 ${kind}`);
|
||||
const real = await recheck(r.abs, r.rootReal);
|
||||
if (r.def.storageId) return downloadRemote(d, principal, q, r.rel, real, name, kind, pk);
|
||||
const file = await Deno.open(real, { read: true });
|
||||
const file = await runtimeOpen(real, { read: true });
|
||||
let size: number;
|
||||
try {
|
||||
const st = await file.stat();
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
FilesScrapeBatchResult,
|
||||
} from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { runtimeRename } from "@umt/runtime";
|
||||
import type { Principal } from "../../http/auth.ts";
|
||||
import { mkdir, readFile, writeFile } from "../../storage/local-fs.ts";
|
||||
import { vlstat } from "../../storage/vfs.ts";
|
||||
@@ -84,7 +85,7 @@ export async function probeFiles(d: FmDeps, body: FilesProbeRequest): Promise<Fi
|
||||
await mkdir(cacheDir, { recursive: true }).catch(() => undefined);
|
||||
const tmp = `${cacheFile}.${randomUUID()}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify({ at: Date.now(), result }))
|
||||
.then(() => Deno.rename(tmp, cacheFile))
|
||||
.then(() => runtimeRename(tmp, cacheFile))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
items.push({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { featureManifest } from "@umt/contracts";
|
||||
import denoJson from "../../../deno.json" with { type: "json" };
|
||||
import packageJson from "../../../package.json" with { type: "json" };
|
||||
import type { ServerModule } from "../types.ts";
|
||||
import { createMcpConfigStore } from "./config-store.ts";
|
||||
import { registerMcpRoutes } from "./routes.ts";
|
||||
@@ -20,7 +20,7 @@ export function createMcpModule(opts: { version?: string } = {}): ServerModule &
|
||||
registerMcpRoutes(app, ctx, {
|
||||
table,
|
||||
config: createMcpConfigStore(() => ctx.db),
|
||||
version: opts.version ?? denoJson.version,
|
||||
version: opts.version ?? packageJson.version,
|
||||
});
|
||||
},
|
||||
registerTasks() {},
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
// @ts-types="@modelcontextprotocol/sdk/server/webStandardStreamableHttp"
|
||||
import type { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
import type { McpSessionInfo } from "@umt/contracts";
|
||||
import { runtimeUnrefTimer } from "@umt/runtime";
|
||||
|
||||
// MCP 会话表(进程内):会话与建立它的 API Key 绑定,换 Key 复用会话一律拒绝。
|
||||
// 空闲超时回收;每个 Key 的会话数与每分钟工具调用数有上限。
|
||||
@@ -128,6 +129,6 @@ export class McpSessionTable {
|
||||
this.timer = setInterval(() => void this.sweep(), 60_000);
|
||||
// 不因为回收定时器挂住进程退出
|
||||
(this.timer as unknown as { unref?: () => void }).unref?.();
|
||||
if (typeof Deno !== "undefined") Deno.unrefTimer(this.timer as unknown as number);
|
||||
runtimeUnrefTimer(this.timer as unknown as number);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import { z } from "zod";
|
||||
import { GRANT_ACTIONS } from "../agent/authorize.ts";
|
||||
import { GRANT_ACTIONS } from "../../authorization/grants.ts";
|
||||
import type { SettingsReader } from "../types.ts";
|
||||
|
||||
// 能力调用统计与能力清单。统计口径是 audit_event(registry.invoke 每次调用都写一条,含只读能力与拒绝);
|
||||
|
||||
@@ -48,7 +48,7 @@ interface Session {
|
||||
/** manual_code 挂起的解析函数 */
|
||||
paste: ((v: string) => void) | null;
|
||||
pasteWaiters: (() => void)[];
|
||||
firstEvent: PromiseWithResolvers<void>;
|
||||
firstEvent: ReturnType<typeof Promise.withResolvers<void>>;
|
||||
done: Promise<void>;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import { registerEpisodeMappingCapabilities } from "./capabilities-episode.ts";
|
||||
import { registerSiteCapabilities } from "./capabilities-sites.ts";
|
||||
import { toApi, toApiList } from "./rows.ts";
|
||||
import type { SourcesRuntime } from "./runtime.ts";
|
||||
import { loadDetailPage } from "./service/detail-page.ts";
|
||||
import { assignReleaseIdentity } from "./service/identity-admin.ts";
|
||||
import { applyReleaseFacts } from "./service/release-facts-admin.ts";
|
||||
import { resolveRelease } from "./service/resolve.ts";
|
||||
import { refreshReleaseDetail } from "./service/snapshot.ts";
|
||||
import { liveSearch } from "./service/workbench.ts";
|
||||
@@ -113,7 +115,7 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: ()
|
||||
|
||||
registry.register(capabilitySpec("release.inspect"), async (input) => {
|
||||
const r = rt();
|
||||
if (input.refreshDetail) await refreshReleaseDetail(r, input.sourceReleaseId);
|
||||
const refreshError = input.refreshDetail ? await refreshReleaseDetail(r, input.sourceReleaseId) : null;
|
||||
const row = await r.ctx.db
|
||||
.selectFrom("source_release")
|
||||
.selectAll()
|
||||
@@ -128,9 +130,18 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: ()
|
||||
.where("media_entity_id", "=", row.media_entity_id)
|
||||
.execute()
|
||||
: [];
|
||||
return { release: toApi(row), inventory: toApiList(inventory) } as never;
|
||||
return {
|
||||
release: toApi(row),
|
||||
inventory: toApiList(inventory),
|
||||
page: refreshError ? null : await loadDetailPage(r, input.sourceReleaseId),
|
||||
refreshError,
|
||||
} as never;
|
||||
});
|
||||
|
||||
registry.register(capabilitySpec("release.apply_facts"), (input, ctx) =>
|
||||
applyReleaseFacts(rt().ctx, input, ctx.agentRunId),
|
||||
);
|
||||
|
||||
registry.register(capabilitySpec("identity.resolve"), async (input, ctx) => {
|
||||
const r = rt();
|
||||
let srId = input.sourceReleaseId ?? null;
|
||||
|
||||
@@ -28,7 +28,7 @@ import { readBrowserUserAgent, resolveUserAgent, type UserAgentDefaults } from "
|
||||
|
||||
export interface RuntimeOverrides {
|
||||
/** 站点与 RSS 请求使用的 fetch(测试指向 mock) */
|
||||
fetch?: typeof globalThis.fetch;
|
||||
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
/** TMDB 客户端工厂;返回 null 表示未配置(只做本地召回) */
|
||||
tmdb?: (() => TmdbClient | null) | undefined;
|
||||
now?: () => Date;
|
||||
@@ -39,7 +39,7 @@ export interface RuntimeOverrides {
|
||||
/** false = 站点限流只用进程内令牌桶(缺省走 PG 跨进程令牌桶 rate_limit_bucket) */
|
||||
sharedRateLimit?: boolean;
|
||||
/** 豆瓣 / bgm.tv 请求用的 fetch;null = 不在线查(测试缺省不出外网) */
|
||||
metaFetch?: typeof globalThis.fetch | null;
|
||||
metaFetch?: ((input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) | null;
|
||||
}
|
||||
|
||||
interface CachedAdapter {
|
||||
@@ -78,9 +78,9 @@ export class SourcesRuntime {
|
||||
* 未配置时回退环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN / TMDB_BASE_URL。设置变化(引用/地址)时重建。
|
||||
*/
|
||||
/** 豆瓣 / bgm.tv 请求:注入优先;测试进程(VITEST)缺省不出外网;否则按环境变量代理出外网 */
|
||||
metaFetch(): typeof globalThis.fetch | null {
|
||||
metaFetch(): ((input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) | null {
|
||||
if (this.overrides.metaFetch !== undefined) return this.overrides.metaFetch;
|
||||
return Deno.env.get("VITEST") ? null : externalFetch;
|
||||
return process.env.VITEST ? null : externalFetch;
|
||||
}
|
||||
|
||||
async tmdb(): Promise<TmdbClient | null> {
|
||||
@@ -110,7 +110,7 @@ export class SourcesRuntime {
|
||||
if (!cfg) return null;
|
||||
let n = 0;
|
||||
const base = cfg.fetch ?? externalFetch;
|
||||
const counted: typeof fetch = (input, init) => {
|
||||
const counted: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> = (input, init) => {
|
||||
n++;
|
||||
return base(input, init);
|
||||
};
|
||||
@@ -122,7 +122,7 @@ export class SourcesRuntime {
|
||||
private async tmdbConfig(): Promise<{ sig: string; cfg: Parameters<typeof createTmdbClient>[0] | null }> {
|
||||
const meta = await this.ctx.settings.get("metadata").catch(() => null);
|
||||
const policy = await tmdbCachePolicy(this.ctx);
|
||||
const env = (k: string) => Deno.env.get(k);
|
||||
const env = (k: string) => process.env[k];
|
||||
const ref = meta?.tmdbApiKeySecretRef ?? null;
|
||||
const baseUrl = meta?.tmdbBaseUrl ?? env("TMDB_BASE_URL");
|
||||
const sig = JSON.stringify([
|
||||
@@ -189,7 +189,7 @@ export class SourcesRuntime {
|
||||
site: Pick<SiteRow, "proxy"> & Partial<Pick<SiteRow, "id">>,
|
||||
defaults: SitesSettings = this.siteDefaultsCache,
|
||||
ua?: string | (() => Promise<string>),
|
||||
): typeof globalThis.fetch {
|
||||
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
||||
const base = this.transportFetch(site, defaults);
|
||||
const egress = this.egressKeyOf(site, defaults);
|
||||
const siteId = site.id;
|
||||
@@ -214,7 +214,7 @@ export class SourcesRuntime {
|
||||
return base(input, { ...init, headers: h, ...(signal ? { signal } : {}) });
|
||||
},
|
||||
);
|
||||
}) as typeof globalThis.fetch;
|
||||
}) as (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
|
||||
/** 站点 HTTP 出口键(direct / proxy:<主机>),与浏览器会话的出口同一口径 */
|
||||
@@ -223,7 +223,10 @@ export class SourcesRuntime {
|
||||
}
|
||||
|
||||
/** 不加 UA 的底层 fetch(测试注入 / 按站点出口:代理或明确直连) */
|
||||
private transportFetch(site: Pick<SiteRow, "proxy">, defaults: SitesSettings): typeof globalThis.fetch {
|
||||
private transportFetch(
|
||||
site: Pick<SiteRow, "proxy">,
|
||||
defaults: SitesSettings,
|
||||
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
||||
if (this.overrides.fetch) return this.overrides.fetch;
|
||||
return siteFetchFor(siteEgress(site.proxy, defaults.proxyUrl));
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function imageMime(b: Uint8Array): string | null {
|
||||
}
|
||||
|
||||
export async function fetchPublicImage(
|
||||
doFetch: typeof fetch,
|
||||
doFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
|
||||
url: string,
|
||||
opts: { maxBytes: number; label: string; referer?: string },
|
||||
): Promise<ImageFetchResult> {
|
||||
|
||||
@@ -26,9 +26,9 @@ export interface PrewarmResult {
|
||||
}
|
||||
|
||||
/** 图片预热用的 fetch:测试注入优先;测试进程没注入就不出外网 */
|
||||
function imageFetch(rt: SourcesRuntime): typeof globalThis.fetch | null {
|
||||
function imageFetch(rt: SourcesRuntime): ((input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) | null {
|
||||
if (rt.overrides.fetch) return rt.overrides.fetch;
|
||||
return Deno.env.get("VITEST") ? null : externalFetch;
|
||||
return process.env.VITEST ? null : externalFetch;
|
||||
}
|
||||
|
||||
export async function prewarmMetadata(rt: SourcesRuntime, mediaEntityId: string): Promise<PrewarmResult> {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { ReleaseApplyFactsInput, ReleaseStaticFacts } from "@umt/contracts";
|
||||
import type { z } from "zod";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import { investigationDecisionId } from "../../agent/runner.ts";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
import { contextOf, parsedOf } from "./release-facts.ts";
|
||||
|
||||
export function missingReleaseFields(row: Parameters<typeof parsedOf>[0], fields: readonly string[]): string[] {
|
||||
const p = parsedOf(row);
|
||||
const c = contextOf(row);
|
||||
const values: Record<string, unknown> = {
|
||||
resolution: p.resolution,
|
||||
codec: p.videoCodec,
|
||||
group: p.releaseGroup,
|
||||
source: p.source,
|
||||
size: p.sizeBytes,
|
||||
subtitles: p.subtitles,
|
||||
seeders: c.seeders,
|
||||
promotion: c.downloadVolumeFactor,
|
||||
site: c.siteId,
|
||||
};
|
||||
return fields.filter((f) => values[f] === undefined || values[f] === null);
|
||||
}
|
||||
|
||||
/** 只填事项正在等待的事实;不接受策略、排名、身份或季集字段。 */
|
||||
export async function applyReleaseFacts(
|
||||
ctx: ServerContext,
|
||||
input: z.infer<typeof ReleaseApplyFactsInput>,
|
||||
runId?: string,
|
||||
) {
|
||||
if (runId) {
|
||||
const run = await ctx.db
|
||||
.selectFrom("agent_run")
|
||||
.select(["dedupe_key", "trigger"])
|
||||
.where("id", "=", runId)
|
||||
.executeTakeFirstOrThrow();
|
||||
if (run.trigger !== "user" && investigationDecisionId(run.dedupe_key) !== input.decisionId)
|
||||
throw new ApiException("forbidden", "只能补全当前调查事项的资源属性");
|
||||
}
|
||||
return ctx.db.transaction().execute(async (trx) => {
|
||||
const decision = await trx
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("id", "=", input.decisionId)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (
|
||||
decision?.subject_type !== "source_release" ||
|
||||
decision.subject_id !== input.sourceReleaseId ||
|
||||
decision.dedupe_key !== `release_facts:${input.sourceReleaseId}`
|
||||
)
|
||||
throw new ApiException("bad_request", "资源属性事项与本次资源不一致");
|
||||
const row = await trx
|
||||
.selectFrom("source_release")
|
||||
.selectAll()
|
||||
.where("id", "=", input.sourceReleaseId)
|
||||
.forUpdate()
|
||||
.executeTakeFirstOrThrow();
|
||||
const fields = ((decision.evidence ?? {}) as { missing?: string[] }).missing ?? [];
|
||||
if (decision.status === "resolved") return { resolved: true, remaining: missingReleaseFields(row, fields) };
|
||||
if (!["open", "investigating", "awaiting_user"].includes(decision.status))
|
||||
throw new ApiException("precondition_failed", "该资源属性事项已关闭");
|
||||
const allowed: Record<keyof ReleaseStaticFacts, string> = {
|
||||
resolution: "resolution",
|
||||
videoCodec: "codec",
|
||||
source: "source",
|
||||
releaseGroup: "group",
|
||||
};
|
||||
const missing = new Set(missingReleaseFields(row, fields));
|
||||
const raw = (row.raw ?? {}) as Record<string, unknown>;
|
||||
const old = (raw._facts ?? {}) as { values?: ReleaseStaticFacts; evidence?: Record<string, unknown> };
|
||||
const facts: ReleaseStaticFacts = {};
|
||||
for (const key of Object.keys(input.facts) as (keyof ReleaseStaticFacts)[]) {
|
||||
if (missing.has(allowed[key])) Object.assign(facts, { [key]: input.facts[key] });
|
||||
else if (!fields.includes(allowed[key]) || old.values?.[key] !== input.facts[key])
|
||||
throw new ApiException("bad_request", `只能补全事项里仍未知的属性:${key}`);
|
||||
}
|
||||
const updated = {
|
||||
...raw,
|
||||
_facts: {
|
||||
values: { ...old.values, ...facts },
|
||||
evidence: {
|
||||
...old.evidence,
|
||||
...Object.fromEntries(
|
||||
Object.keys(facts).map((field) => [
|
||||
field,
|
||||
{
|
||||
text: input.evidence,
|
||||
url: input.evidenceUrl ?? null,
|
||||
runId: runId ?? null,
|
||||
at: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
await trx
|
||||
.updateTable("source_release")
|
||||
.set({ raw: JSON.stringify(updated) })
|
||||
.where("id", "=", row.id)
|
||||
.execute();
|
||||
const remaining = missingReleaseFields({ ...row, raw: updated }, fields);
|
||||
if (remaining.length === 0) {
|
||||
await trx
|
||||
.updateTable("decision")
|
||||
.set({
|
||||
status: "resolved",
|
||||
resolution: JSON.stringify({ factsApplied: true, evidence: input.evidence }),
|
||||
resolved_at: new Date(),
|
||||
})
|
||||
.where("id", "=", decision.id)
|
||||
.execute();
|
||||
await ctx.outbox.emit(
|
||||
trx,
|
||||
"decision.updated",
|
||||
{ decisionId: decision.id, status: "resolved" },
|
||||
{ idempotencyKey: `decision.updated:${decision.id}:facts` },
|
||||
);
|
||||
}
|
||||
if (row.media_entity_id)
|
||||
await ctx.outbox.emit(
|
||||
trx,
|
||||
"release.resolved",
|
||||
{
|
||||
sourceReleaseId: row.id,
|
||||
observationId: null,
|
||||
mediaEntityId: row.media_entity_id,
|
||||
contentUnitIds: row.content_unit_id ? [row.content_unit_id] : [],
|
||||
evidenceLevel: "needs_review",
|
||||
},
|
||||
{
|
||||
idempotencyKey: `release.resolved:${row.id}:facts:${decision.id}:${JSON.stringify(updated._facts.values)}:${JSON.stringify([...remaining].sort())}`,
|
||||
},
|
||||
);
|
||||
return { resolved: remaining.length === 0, remaining };
|
||||
});
|
||||
}
|
||||
+10
@@ -1,3 +1,4 @@
|
||||
import { ReleaseStaticFacts } from "@umt/contracts";
|
||||
import type { SourceReleaseRow } from "@umt/db";
|
||||
import { type EvaluationContext, type ParsedRelease, parseRelease } from "@umt/domain";
|
||||
import { fromParsedColumns } from "@umt/mapping";
|
||||
@@ -29,6 +30,15 @@ export function parsedOf(r: SourceReleaseRow): ParsedRelease {
|
||||
p = { ...rest, season: c.season, episodes: { ...c.episodes }, seasonInferred: false };
|
||||
}
|
||||
}
|
||||
const facts = ReleaseStaticFacts.safeParse((r.raw as { _facts?: { values?: unknown } } | null)?._facts?.values).data;
|
||||
if (facts)
|
||||
p = {
|
||||
...p,
|
||||
...(!p.resolution && facts.resolution ? { resolution: facts.resolution } : {}),
|
||||
...(!p.videoCodec && facts.videoCodec ? { videoCodec: facts.videoCodec } : {}),
|
||||
...(!p.source && facts.source ? { source: facts.source, isEncode: facts.source === "BluRay" } : {}),
|
||||
...(!p.releaseGroup && facts.releaseGroup ? { releaseGroup: { name: facts.releaseGroup, source: "known" } } : {}),
|
||||
};
|
||||
return r.size_bytes !== null ? { ...p, sizeBytes: r.size_bytes } : p;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
toParsedColumns,
|
||||
toReleaseResolutionStatus,
|
||||
} from "@umt/mapping";
|
||||
import { rewriteHook } from "../../../hooks/index.ts";
|
||||
import { jsonb } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { addSiteAliases, type SiteAliasOutcome } from "./alias-guard.ts";
|
||||
@@ -484,18 +483,7 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
|
||||
mediaEntityId: release.media_entity_id,
|
||||
};
|
||||
}
|
||||
// 链式钩子 name.recognize(ADR-006):可改写解析结果;没有钩子时原样
|
||||
const parsed = await rewriteHook(
|
||||
ctx,
|
||||
"name.recognize",
|
||||
await parseTitle(ctx.db, release.title, release.subtitle),
|
||||
() => ({
|
||||
title: release.title,
|
||||
subtitle: release.subtitle ?? null,
|
||||
sourceReleaseId: release.id,
|
||||
trigger: input.trigger ?? (input.force ? "review" : "observed"),
|
||||
}),
|
||||
);
|
||||
const parsed = await parseTitle(ctx.db, release.title, release.subtitle);
|
||||
const cols = toParsedColumns(parsed);
|
||||
// 站点给出的外部 ID(列表 / 详情页的 IMDb、豆瓣、TMDB 链接)作为显式 ID 证据参与打分
|
||||
const siteIds = siteExplicitIds(release.raw);
|
||||
@@ -1184,13 +1172,7 @@ export async function identifyParsed(
|
||||
const { ctx } = rt;
|
||||
const none = { mediaEntityId: null, contentUnitIds: [] as string[], candidates: [], created: false };
|
||||
const trigger = opts.trigger ?? "takeover";
|
||||
// 链式钩子 name.recognize(ADR-006):整理 / 接管按名称识别时同样可改写解析结果
|
||||
const parsed = await rewriteHook(ctx, "name.recognize", input, () => ({
|
||||
title: input.raw,
|
||||
subtitle: null,
|
||||
sourceReleaseId: null,
|
||||
trigger,
|
||||
}));
|
||||
const parsed = input;
|
||||
if (!parsed.title || parsed.auxiliaryOnly) return { outcome: "unparseable", reason: "unparseable_title", ...none };
|
||||
const local = await localCandidates(ctx.db, parsed, null);
|
||||
const lookup = await lookupReason(rt, parsed, trigger, local.length > 0, null);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { hostname } from "node:os";
|
||||
import type { ProcessKind } from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import { runtimeHostname, runtimeUnrefTimer } from "@umt/runtime";
|
||||
import type { Logger } from "pino";
|
||||
import serverDeno from "../../../deno.json" with { type: "json" };
|
||||
import serverPackage from "../../../package.json" with { type: "json" };
|
||||
import { createProcessCpuMeter } from "./resources.ts";
|
||||
|
||||
// 进程心跳写入(W9-I):每个常驻进程每 15s upsert process_heartbeat 一行 + 追加一条采样。
|
||||
@@ -19,7 +20,7 @@ export const HEARTBEAT_ROW_RETENTION_MS = 24 * 3600_000;
|
||||
/** 当前实例判定窗口:最近这段时间内有心跳的 host 视为当前在跑的容器 */
|
||||
export const CURRENT_HOST_WINDOW_MS = 10 * 60_000;
|
||||
|
||||
export const SERVER_VERSION: string = serverDeno.version;
|
||||
export const SERVER_VERSION: string = serverPackage.version;
|
||||
|
||||
/** 事件循环延迟采样器:每 intervalMs 测一次 setInterval 实际触发偏差;window 在每次心跳读取后重置 */
|
||||
export function createLagSampler(intervalMs = 1000) {
|
||||
@@ -36,7 +37,7 @@ export function createLagSampler(intervalMs = 1000) {
|
||||
if (lag > lifetimeMax) lifetimeMax = lag;
|
||||
}, intervalMs);
|
||||
try {
|
||||
Deno.unrefTimer(timer);
|
||||
runtimeUnrefTimer(timer);
|
||||
} catch {
|
||||
// 非 Deno 运行时(vitest 走 deno,理论上不会到这里)
|
||||
}
|
||||
@@ -80,7 +81,7 @@ export function instanceIdOf(kind: ProcessKind, host: string, pid: number) {
|
||||
|
||||
function memory(): Record<string, number> {
|
||||
try {
|
||||
const m = Deno.memoryUsage();
|
||||
const m = process.memoryUsage();
|
||||
return { rssBytes: m.rss, heapUsedBytes: m.heapUsed };
|
||||
} catch {
|
||||
return {};
|
||||
@@ -159,7 +160,7 @@ export interface HeartbeatWriter {
|
||||
/** 起一个周期写入器:立即写一次,之后每 intervalMs 写一次;stop 时把本行标 stopped_at */
|
||||
export function startHeartbeatWriter(o: HeartbeatWriterOptions): HeartbeatWriter {
|
||||
const host = o.host ?? safeHostname();
|
||||
const pid = o.pid ?? Deno.pid;
|
||||
const pid = o.pid ?? process.pid;
|
||||
const startedAt = o.startedAt ?? new Date();
|
||||
const version = o.version ?? SERVER_VERSION;
|
||||
const instanceId = instanceIdOf(o.kind, host, pid);
|
||||
@@ -214,7 +215,7 @@ export function startHeartbeatWriter(o: HeartbeatWriterOptions): HeartbeatWriter
|
||||
void beat();
|
||||
const timer = setInterval(() => void beat(), intervalMs);
|
||||
try {
|
||||
Deno.unrefTimer(timer);
|
||||
runtimeUnrefTimer(timer);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -237,7 +238,7 @@ export function startHeartbeatWriter(o: HeartbeatWriterOptions): HeartbeatWriter
|
||||
|
||||
export function safeHostname(): string {
|
||||
try {
|
||||
return Deno.hostname();
|
||||
return runtimeHostname();
|
||||
} catch {
|
||||
return hostname();
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ export function createSystemModule(opts: SystemModuleOptions = {}): ServerModule
|
||||
if (opts.resourceSampler) sampler = opts.resourceSampler;
|
||||
else if (opts.resourceSampler !== false && !sampler) sampler = createResourceSampler();
|
||||
const selfKind = ctx.role === "all" ? "all" : ctx.role === "api" ? "api" : null;
|
||||
const selfId = selfKind ? instanceIdOf(selfKind, safeHostname(), Deno.pid) : undefined;
|
||||
const selfId = selfKind ? instanceIdOf(selfKind, safeHostname(), process.pid) : undefined;
|
||||
const paths = (): StoragePaths => {
|
||||
if (opts.storagePaths) return opts.storagePaths;
|
||||
const p = dataPaths(ctx.config);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { availableParallelism, cpus } from "node:os";
|
||||
import process from "node:process";
|
||||
import type { ResourceSample, ResourceScope, SystemResources } from "@umt/contracts";
|
||||
import { runtimeUnrefTimer } from "@umt/runtime";
|
||||
import { readProcText } from "../../proc-read.ts";
|
||||
|
||||
// 资源监控(界面第二轮 V 线,参考 MP 仪表盘 CPU / 内存 / 网络组件):api 进程每 5 秒读一次 /proc 与 cgroup v2,
|
||||
@@ -366,7 +367,7 @@ export function createResourceSampler(
|
||||
void tick();
|
||||
timer = setInterval(() => void tick(), intervalMs);
|
||||
try {
|
||||
Deno.unrefTimer(timer);
|
||||
runtimeUnrefTimer(timer);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -12,13 +12,11 @@ import type { DB, Kysely, Transaction } from "@umt/db";
|
||||
import type { OutboxRelay, OutboxRouteFn, TaskEngine, TaskRegistry } from "@umt/tasks";
|
||||
import type { Logger } from "pino";
|
||||
import type { Config } from "../config.ts";
|
||||
import type { HookRegistrar, HookRegistry } from "../hooks/registry.ts";
|
||||
import type { AuthEnv } from "../http/auth.ts";
|
||||
import type { EventBusPort } from "../http/sse.ts";
|
||||
|
||||
// 服务端模块骨架(W1 审查 B6;任务书 §5)。
|
||||
// 每个 W2 子任务是一个 ServerModule,只改 modules/<自己的目录>/ 与 contracts/db 中本域的文件。
|
||||
// 跨模块只通过 DB 表 + outbox_event(事件类型在 contracts/events.ts)+ contracts 类型耦合,不互相 import。
|
||||
// 服务端模块装配。同步协作使用明确的业务服务导出,事后通知走 outbox;
|
||||
// 路由和能力处理器依赖服务,业务服务不反向依赖 HTTP 路由或 Agent 会话。
|
||||
|
||||
export type ServerRole = "api" | "worker" | "worker:agent" | "all";
|
||||
|
||||
@@ -49,8 +47,8 @@ export interface OutboxPort extends OutboxWriter {
|
||||
}
|
||||
|
||||
/**
|
||||
* 凭证存取(secret 表 + SECRET_MASTER_KEY 加密)。W2-A 在 modules/auth-settings/provided.ts 实现,
|
||||
* 当前占位实现一律抛"未实现"。其它模块只存/传 secret_ref,需要明文时现场 resolve,不缓存、不打日志。
|
||||
* 凭证存取(secret 表 + SECRET_MASTER_KEY 加密),由 modules/auth-settings/provided.ts 实现。
|
||||
* 其它模块只存/传 secret_ref,需要明文时现场 resolve,不缓存、不打日志。
|
||||
*/
|
||||
export interface SecretStore {
|
||||
resolve(secretRef: string): Promise<string>;
|
||||
@@ -80,11 +78,6 @@ export interface ServerContext {
|
||||
settings: SettingsReader;
|
||||
/** 能力注册中心(Web/Pi/CLI/MCP 共用一条 invoke 路径) */
|
||||
capabilities: CapabilityRegistry;
|
||||
/**
|
||||
* 链式钩子(ADR-006):bootstrap 装配,模块在 registerHooks 里注册;业务节点经 hooks/index.ts 的
|
||||
* rewriteHook / interceptHook 调用。测试里手搭的上下文可以没有(等同于没有注册)。
|
||||
*/
|
||||
hooks?: HookRegistry;
|
||||
}
|
||||
|
||||
export interface ServerModule {
|
||||
@@ -94,7 +87,7 @@ export interface ServerModule {
|
||||
registerRoutes(app: ModuleApp, ctx: ServerContext): void;
|
||||
/**
|
||||
* 所有角色:注册持久任务处理器(api 角色也要注册,enqueue 时按定义校验输入)。
|
||||
* 同名任务只能由一个模块注册;没人注册的 §3.1-8 任务由 @umt/tasks 的空壳兜底。
|
||||
* 同名任务只能由一个模块注册;未注册的任务明确报错。
|
||||
*/
|
||||
registerTasks(registry: TaskRegistry, ctx: ServerContext): void;
|
||||
/**
|
||||
@@ -104,8 +97,6 @@ export interface ServerModule {
|
||||
outboxRoutes?(ctx: ServerContext): Record<string, OutboxRouteFn>;
|
||||
/** 外围功能的说明(功能清单只给智能体看,不做开关);取 contracts 的 featureManifest(id) */
|
||||
manifest?: FeatureManifest;
|
||||
/** 所有角色:注册链式钩子(同步、按注册顺序、可改写或拦截;见 hooks/registry.ts) */
|
||||
registerHooks?(hooks: HookRegistrar, ctx: ServerContext): void;
|
||||
/** 所有角色:用 contracts 的 capabilitySpec(id) 注册能力处理器 */
|
||||
registerCapabilities?(registry: CapabilityRegistry, ctx: ServerContext): void;
|
||||
/** worker / all 角色:启动轮询器、定时器等后台循环(引擎与中继已启动) */
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { ServerContext } from "../types.ts";
|
||||
|
||||
export interface WebSearchModuleOptions {
|
||||
/** 搜索与网页读取用的 fetch(测试注入 mock) */
|
||||
fetch?: typeof fetch;
|
||||
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
/** Brave 基址(测试指向 mock) */
|
||||
braveBaseUrl?: string;
|
||||
/** DNS 解析(测试注入);缺省系统解析 */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { dirname } from "node:path";
|
||||
import type { DB, Kysely } from "@umt/db";
|
||||
import { sql } from "@umt/db";
|
||||
import { runtimeMkdir, runtimeRename, runtimeWriteTextFile } from "@umt/runtime";
|
||||
|
||||
// 运维一次性子命令(备份/恢复脚本调用):映射清单导出、恢复后打开只读对账。
|
||||
// 映射清单是"路径不携带完整身份"前提下的关键资产(需求 §8.5):数据库之外再留一份可读 JSON,
|
||||
@@ -112,10 +113,10 @@ export async function exportMappings(db: Kysely<DB>, appVersion: string): Promis
|
||||
|
||||
/** 写 JSON(先写临时文件再改名,避免半截文件) */
|
||||
export async function writeMappingExport(path: string, data: MappingExport): Promise<void> {
|
||||
await Deno.mkdir(dirname(path), { recursive: true });
|
||||
await runtimeMkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.partial`;
|
||||
await Deno.writeTextFile(tmp, `${JSON.stringify(data, null, 2)}\n`);
|
||||
await Deno.rename(tmp, path);
|
||||
await runtimeWriteTextFile(tmp, `${JSON.stringify(data, null, 2)}\n`);
|
||||
await runtimeRename(tmp, path);
|
||||
}
|
||||
|
||||
/** 两份清单逐表比行数,返回不一致的表 */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
import type { DB, Kysely } from "@umt/db";
|
||||
import { sql } from "@umt/db";
|
||||
import { runtimeMkdir, runtimeReadDir, runtimeRemove, runtimeStat, runtimeWriteTextFile } from "@umt/runtime";
|
||||
import type { Config } from "./config.ts";
|
||||
|
||||
export interface PreflightProblem {
|
||||
@@ -19,7 +20,11 @@ export interface PreflightDeps {
|
||||
const PLACEHOLDER = /change[-_]?me|replace[-_]?me|dev[-_]?only|example/i;
|
||||
|
||||
/** 启动自检:返回问题列表,空数组表示通过。不打印变量值。 */
|
||||
export async function runPreflight({ config, db, uid = Deno.uid() }: PreflightDeps): Promise<PreflightProblem[]> {
|
||||
export async function runPreflight({
|
||||
config,
|
||||
db,
|
||||
uid = process.getuid?.(),
|
||||
}: PreflightDeps): Promise<PreflightProblem[]> {
|
||||
const problems: PreflightProblem[] = [];
|
||||
const prod = config.NODE_ENV === "production";
|
||||
|
||||
@@ -36,7 +41,7 @@ export async function runPreflight({ config, db, uid = Deno.uid() }: PreflightDe
|
||||
}
|
||||
if (config.WEB_ROOT) {
|
||||
try {
|
||||
await Deno.stat(join(config.WEB_ROOT, "index.html"));
|
||||
await runtimeStat(join(config.WEB_ROOT, "index.html"));
|
||||
} catch {
|
||||
problems.push({ check: "web", message: `WEB_ROOT 下没有 index.html: ${config.WEB_ROOT}` });
|
||||
}
|
||||
@@ -66,9 +71,9 @@ const errText = (e: unknown) => (e as { code?: string }).code ?? (e as Error).me
|
||||
|
||||
/** 在目录里写一个探针文件再删掉;失败抛错 */
|
||||
async function probeWrite(dir: string): Promise<void> {
|
||||
const probe = join(dir, `.umt-write-probe-${Deno.pid}-${crypto.randomUUID().slice(0, 8)}`);
|
||||
await Deno.writeTextFile(probe, "probe\n");
|
||||
await Deno.remove(probe);
|
||||
const probe = join(dir, `.umt-write-probe-${process.pid}-${crypto.randomUUID().slice(0, 8)}`);
|
||||
await runtimeWriteTextFile(probe, "probe\n");
|
||||
await runtimeRemove(probe);
|
||||
}
|
||||
|
||||
/** 读出已启用的库根与下载器路径映射在本进程视角下的路径 */
|
||||
@@ -109,7 +114,7 @@ export async function checkStorage(opts: {
|
||||
if (!dirs.includes(opts.sessionDir)) dirs.push(opts.sessionDir);
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
await Deno.mkdir(dir, { recursive: true });
|
||||
await runtimeMkdir(dir, { recursive: true });
|
||||
await probeWrite(dir);
|
||||
} catch (e) {
|
||||
// copy-allow: 括号里是系统错误原文
|
||||
@@ -118,9 +123,9 @@ export async function checkStorage(opts: {
|
||||
}
|
||||
for (const m of opts.mounts) {
|
||||
try {
|
||||
const st = await Deno.stat(m.path);
|
||||
const st = await runtimeStat(m.path);
|
||||
if (!st.isDirectory) throw Object.assign(new Error("不是目录"), { code: "ENOTDIR" });
|
||||
for await (const _ of Deno.readDir(m.path)) break;
|
||||
for await (const _ of await runtimeReadDir(m.path)) break;
|
||||
} catch (e) {
|
||||
warnings.push({ ...m, reason: `不可读:${errText(e)}` });
|
||||
continue;
|
||||
@@ -146,7 +151,7 @@ export async function auditStorageWarnings(db: Kysely<DB>, warnings: StorageWarn
|
||||
action: "preflight.path_unavailable",
|
||||
target_type: w.kind,
|
||||
target_id: w.id,
|
||||
payload: JSON.stringify({ name: w.name, path: w.path, reason: w.reason, uid: Deno.uid() }),
|
||||
payload: JSON.stringify({ name: w.name, path: w.path, reason: w.reason, uid: process.getuid?.() }),
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
|
||||
@@ -1,29 +1,9 @@
|
||||
// 读 /proc 与 /sys 的伪文件。
|
||||
// Deno 2 把这两处当特权路径:不带 --allow-all 时 Deno.readTextFile 一律 NotCapable(--allow-read 及按路径授权都不行)。
|
||||
// 开发 / 测试用 -A 能直接读;编译产物按最小权限运行,只能借 `cat` 子进程读(编译与 start 任务的 --allow-run 已含 cat)。
|
||||
// 第一次遇到 NotCapable 后记住,后续直接走 cat。
|
||||
|
||||
let viaCat = false;
|
||||
|
||||
async function catText(path: string): Promise<string | null> {
|
||||
import { readFile } from "node:fs/promises";
|
||||
/** Linux 资源监控和子进程 RSS;权限不足或进程已退出时返回 null。 */
|
||||
export async function readProcText(path: string): Promise<string | null> {
|
||||
try {
|
||||
const o = await new Deno.Command("cat", { args: [path], stdout: "piped", stderr: "null" }).output();
|
||||
return o.success ? new TextDecoder().decode(o.stdout) : null;
|
||||
return await readFile(path, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 读不到(不存在、无权限、cat 不可用)返回 null */
|
||||
export async function readProcText(path: string): Promise<string | null> {
|
||||
if (viaCat) return await catText(path);
|
||||
try {
|
||||
return await Deno.readTextFile(path);
|
||||
} catch (e) {
|
||||
if (e instanceof Deno.errors.NotCapable) {
|
||||
viaCat = true;
|
||||
return await catText(path);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
// 压缩包解压(沙箱内):zip 用 fflate,rar 用 node-unrar-js(WASM),7z 用 7z-wasm。
|
||||
// WASM 库要读自己包里的 .wasm 文件:prepareArchives() 在撤销权限前预热(载入内存),之后只在内存里解压。
|
||||
import * as SevenZipModule from "7z-wasm";
|
||||
import sevenWasmPath from "7z-wasm/7zz.wasm" with { type: "file" };
|
||||
import { unzipSync } from "fflate";
|
||||
import { createExtractorFromData } from "node-unrar-js";
|
||||
import unrarWasmPath from "node-unrar-js/dist/js/unrar.wasm" with { type: "file" };
|
||||
|
||||
/** 用到的 7z-wasm 接口(Emscripten 实例:内存文件系统 + callMain) */
|
||||
interface Seven {
|
||||
@@ -17,6 +19,7 @@ interface Seven {
|
||||
}
|
||||
// 7z-wasm 是 CJS 包:tsc 与 deno check 对默认导出的理解不同,运行时取 default 或模块本身
|
||||
const SevenZip = ((SevenZipModule as unknown as { default?: unknown }).default ?? SevenZipModule) as unknown as (opts: {
|
||||
wasmBinary: Uint8Array;
|
||||
print: (s: string) => void;
|
||||
printErr: (s: string) => void;
|
||||
}) => Promise<Seven>;
|
||||
@@ -27,11 +30,18 @@ export async function prepareArchives(): Promise<void> {
|
||||
if (warmed) return;
|
||||
warmed = true;
|
||||
try {
|
||||
await createExtractorFromData({ data: new Uint8Array([0x52, 0x61, 0x72, 0x21]).buffer });
|
||||
await createExtractorFromData({
|
||||
data: new Uint8Array([0x52, 0x61, 0x72, 0x21]).buffer,
|
||||
wasmBinary: await Bun.file(unrarWasmPath).arrayBuffer(),
|
||||
});
|
||||
} catch {
|
||||
// 只为载入 WASM,内容无效报错是预期的
|
||||
}
|
||||
seven = await SevenZip({ print: () => {}, printErr: () => {} });
|
||||
seven = await SevenZip({
|
||||
wasmBinary: new Uint8Array(await Bun.file(sevenWasmPath).arrayBuffer()),
|
||||
print: () => {},
|
||||
printErr: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
export type AcceptEntry = (name: string, size: number) => boolean;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// 沙箱调用(docs/adr/ADR-005-sandbox.md):每次一个子进程,权限全部撤销;超时 SIGKILL,子进程实际内存(RSS)超限 SIGKILL。
|
||||
// 起子进程方式同 fsproxy:deno CLI → `deno run worker.ts`(开发 / 测试);编译二进制 → `<自身> --role sandbox`。
|
||||
// 起不来(权限 / 运行时不支持)时退回进程内执行并记日志:仍有超时,但卡死的同步代码无法收回(只在本机调试时出现)。
|
||||
// 解析沙箱:预加载后安装 Linux seccomp(ADR-007),父进程实施超时与 RSS 强杀。
|
||||
// Bun CLI 启动 worker.ts;编译产物启动自身 --role sandbox。建立隔离失败直接拒绝。
|
||||
import { basename } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { WorkerConnection, WorkerTransportError } from "@umt/runtime/worker";
|
||||
import { readProcText } from "../proc-read.ts";
|
||||
import { prepareJobs, SANDBOX_JOBS, type SandboxInput, type SandboxJobName, type SandboxOutput } from "./jobs.ts";
|
||||
import type { SandboxInput, SandboxJobName, SandboxOutput } from "./jobs.ts";
|
||||
import { SANDBOX_MESSAGE_BYTES } from "./limits.ts";
|
||||
|
||||
export type SandboxErrorKind = "timeout" | "memory" | "crash" | "job" | "spawn";
|
||||
|
||||
@@ -27,37 +28,23 @@ export interface SandboxOptions {
|
||||
}
|
||||
|
||||
const WORKER_PATH = fileURLToPath(new URL("./worker.ts", import.meta.url));
|
||||
let inProcessReason: string | null = null;
|
||||
let spawnFailureReason: string | null = null;
|
||||
const stats = { spawned: 0, timeouts: 0, memoryKills: 0, crashes: 0 };
|
||||
|
||||
export function sandboxStats() {
|
||||
return { ...stats, inProcessReason };
|
||||
return { ...stats, spawnFailureReason };
|
||||
}
|
||||
|
||||
function command(memoryMb: number): { cmd: string; args: string[] } {
|
||||
const exe = Deno.execPath();
|
||||
if (/^deno(\.exe)?$/.test(basename(exe)))
|
||||
return {
|
||||
cmd: exe,
|
||||
args: [
|
||||
"run",
|
||||
"-A",
|
||||
"--quiet",
|
||||
"--no-prompt", // V8 堆上限只作兜底(设为两倍):主要靠下面的 RSS 看门狗,编译产物不认 V8 参数,两种模式一致
|
||||
`--v8-flags=--max-old-space-size=${memoryMb * 2}`,
|
||||
WORKER_PATH,
|
||||
],
|
||||
};
|
||||
return { cmd: exe, args: ["--role", "sandbox"] };
|
||||
function command(): { cmd: string; args: string[] } {
|
||||
const exe = process.execPath;
|
||||
return /^bun(\.exe)?$/.test(basename(exe))
|
||||
? { cmd: exe, args: ["--no-env-file", "--config=/dev/null", WORKER_PATH] }
|
||||
: { cmd: exe, args: ["--role", "sandbox"] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 子进程环境:清空,只留 PATH。编译产物启动时要按 PATH 解析 --allow-run 里的命令名(ffprobe、cat…),
|
||||
* 没有 PATH 会在进程入口报「no path to search」直接退出,所有沙箱任务都失败(2026-10 Bangumi 热门、字幕验证码即此)。
|
||||
* 子进程随后撤销全部权限,PATH 不放大能力。
|
||||
*/
|
||||
/** 清空继承环境,只保留 PATH;CLI 禁用 dotenv,编译产物禁用自动配置加载。 */
|
||||
export function sandboxEnv(): Record<string, string> {
|
||||
return { PATH: Deno.env.get("PATH") || "/usr/local/bin:/usr/bin:/bin" };
|
||||
return { PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin" };
|
||||
}
|
||||
|
||||
/** 子进程实际内存(KB);读不到返回 null */
|
||||
@@ -75,95 +62,61 @@ export async function runSandbox<J extends SandboxJobName>(
|
||||
const timeoutMs = opts.timeoutMs ?? 20_000;
|
||||
const memoryMb = opts.memoryMb ?? 512;
|
||||
opts.signal?.throwIfAborted();
|
||||
if (inProcessReason) return runInProcess(job, input, timeoutMs) as Promise<SandboxOutput<J>>;
|
||||
const { cmd, args } = command(memoryMb);
|
||||
let child: Deno.ChildProcess;
|
||||
const { cmd, args } = command();
|
||||
let connection: WorkerConnection;
|
||||
try {
|
||||
child = new Deno.Command(cmd, {
|
||||
args,
|
||||
stdin: "piped",
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
env: sandboxEnv(),
|
||||
clearEnv: true,
|
||||
}).spawn();
|
||||
} catch (e) {
|
||||
inProcessReason = (e as Error).message;
|
||||
console.warn(`[sandbox] 起不来子进程,退回进程内执行:${inProcessReason}`);
|
||||
return runInProcess(job, input, timeoutMs) as Promise<SandboxOutput<J>>;
|
||||
connection = new WorkerConnection({ cmd, args }, { values: sandboxEnv(), clear: true });
|
||||
} catch (error) {
|
||||
spawnFailureReason = (error as Error).message;
|
||||
throw new SandboxError("spawn", `沙箱子进程无法启动:${spawnFailureReason}`);
|
||||
}
|
||||
stats.spawned += 1;
|
||||
let killedFor: SandboxErrorKind | null = null;
|
||||
const kill = (why: SandboxErrorKind) => {
|
||||
const kill = (kind: SandboxErrorKind, message: string) => {
|
||||
if (killedFor) return;
|
||||
killedFor = why;
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// 已退出
|
||||
}
|
||||
killedFor = kind;
|
||||
connection.terminate(new SandboxError(kind, message));
|
||||
};
|
||||
const timer = setTimeout(() => kill("timeout"), timeoutMs);
|
||||
const timer = setTimeout(() => kill("timeout", `沙箱任务 ${job} 超过 ${timeoutMs} ms 未完成,已强制结束`), timeoutMs);
|
||||
const watch = setInterval(async () => {
|
||||
const kb = await rssKb(child.pid);
|
||||
if (kb !== null && kb > memoryMb * 1024) kill("memory");
|
||||
const kb = await rssKb(connection.pid);
|
||||
if (kb !== null && kb > memoryMb * 1024) kill("memory", `沙箱任务 ${job} 内存超过 ${memoryMb} MB,已强制结束`);
|
||||
}, 100);
|
||||
const onAbort = () => kill("crash");
|
||||
const onAbort = () => kill("crash", "沙箱任务已取消");
|
||||
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
const w = child.stdin.getWriter();
|
||||
await w.write(new TextEncoder().encode(JSON.stringify({ job, input }))).catch(() => undefined);
|
||||
await w.close().catch(() => undefined);
|
||||
const [status, stdout, stderr] = await Promise.all([
|
||||
child.status,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
]);
|
||||
if (killedFor === "timeout") {
|
||||
stats.timeouts += 1;
|
||||
throw new SandboxError("timeout", `沙箱任务 ${job} 超过 ${timeoutMs} ms 未完成,已强制结束`);
|
||||
const pending = connection.request(
|
||||
{ id: "1", task: job, input },
|
||||
{
|
||||
maxRequestBytes: SANDBOX_MESSAGE_BYTES,
|
||||
maxResponseBytes: SANDBOX_MESSAGE_BYTES,
|
||||
},
|
||||
);
|
||||
if (opts.signal?.aborted) onAbort();
|
||||
const reply = await pending;
|
||||
if (reply.type === "error") throw new SandboxError("job", reply.error.message);
|
||||
return reply.result as SandboxOutput<J>;
|
||||
} catch (error) {
|
||||
opts.signal?.throwIfAborted();
|
||||
if (error instanceof SandboxError) {
|
||||
if (error.kind === "timeout") stats.timeouts += 1;
|
||||
if (error.kind === "memory") stats.memoryKills += 1;
|
||||
throw error;
|
||||
}
|
||||
if (killedFor === "memory" || /out of memory|heap limit/i.test(stderr)) {
|
||||
if (error instanceof WorkerTransportError && error.kind === "spawn") {
|
||||
spawnFailureReason = (error.cause as Error)?.message ?? error.message;
|
||||
throw new SandboxError("spawn", `沙箱子进程无法启动:${spawnFailureReason}`);
|
||||
}
|
||||
if (error instanceof WorkerTransportError && error.kind === "size") throw new SandboxError("job", error.message);
|
||||
if (/out of memory|heap limit/i.test((error as Error).message)) {
|
||||
stats.memoryKills += 1;
|
||||
throw new SandboxError("memory", `沙箱任务 ${job} 内存超过 ${memoryMb} MB,已强制结束`);
|
||||
}
|
||||
opts.signal?.throwIfAborted();
|
||||
const line = stdout.trim().split("\n").pop() ?? "";
|
||||
let msg: { ok: boolean; result?: unknown; error?: string };
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
stats.crashes += 1;
|
||||
throw new SandboxError("crash", `沙箱任务 ${job} 异常退出(code ${status.code}):${stderr.trim().slice(-300)}`);
|
||||
}
|
||||
if (!msg.ok) throw new SandboxError("job", msg.error ?? "沙箱任务失败");
|
||||
return msg.result as SandboxOutput<J>;
|
||||
stats.crashes += 1;
|
||||
throw new SandboxError("crash", (error as Error).message);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
clearInterval(watch);
|
||||
opts.signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
async function runInProcess<J extends SandboxJobName>(
|
||||
job: J,
|
||||
input: SandboxInput<J>,
|
||||
timeoutMs: number,
|
||||
): Promise<unknown> {
|
||||
await prepareJobs();
|
||||
const fn = SANDBOX_JOBS[job] as (i: SandboxInput<J>) => unknown;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(() => fn(input)),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new SandboxError("timeout", `沙箱任务 ${job} 超时(进程内)`)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} catch (e) {
|
||||
if (e instanceof SandboxError) throw e;
|
||||
throw new SandboxError("job", `${(e as Error).name}: ${(e as Error).message}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { connect } from "node:net";
|
||||
// 沙箱任务表(docs/adr/ADR-005-sandbox.md):只放「外部不可信字节 → 结构化数据」的纯函数。
|
||||
// 子进程加载完本表后撤销全部权限(文件 / 网络 / 环境变量 / 子进程),所以任务里不能有任何 I/O;
|
||||
// 输入输出必须能 JSON 化(二进制用 base64)。新增任务:在这里加一项,调用方用 runSandbox(名字, 输入)。
|
||||
import { parseBangumiBrowser, parseTorrent } from "@umt/adapters";
|
||||
import { decodeSubtitle, detectSubtitleLanguage, subtitleEpisode, subtitleExtOf } from "@umt/domain";
|
||||
import { runtimeReadTextFileSync } from "@umt/runtime";
|
||||
import { buildPreview } from "../modules/file-manager/preview-build.ts";
|
||||
import { PAGE_PARSERS, type PageKind } from "../modules/subtitles/online/parse.ts";
|
||||
import { archiveKindOf, extractArchive, prepareArchives } from "./archives.ts";
|
||||
@@ -96,14 +100,30 @@ function torrentParse(input: { base64: string }) {
|
||||
}
|
||||
|
||||
/** 测试用:按指令卡死 / 吃内存 / 抛错,验证父进程的超时强杀与内存上限 */
|
||||
function selfTest(input: { mode: "ok" | "hang" | "memory" | "throw" | "io" }) {
|
||||
async function selfTest(input: {
|
||||
mode: "ok" | "hang" | "memory" | "throw" | "io" | "async-io" | "write" | "net" | "exec" | "env";
|
||||
}) {
|
||||
if (input.mode === "hang") for (;;) {}
|
||||
if (input.mode === "memory") {
|
||||
const keep: Uint8Array[] = [];
|
||||
for (;;) keep.push(new Uint8Array(16 * 1024 * 1024).fill(1));
|
||||
}
|
||||
if (input.mode === "throw") throw new Error("任务自己抛的错");
|
||||
if (input.mode === "io") return { read: Deno.readTextFileSync("/etc/hostname") };
|
||||
if (input.mode === "env") return { keys: Object.keys(process.env) };
|
||||
if (input.mode === "async-io") return { read: await readFile("/etc/hostname", "utf8") };
|
||||
if (input.mode === "write") return await writeFile("/tmp/umt-sandbox-denied", "denied");
|
||||
if (input.mode === "net") {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const socket = connect({ host: "127.0.0.1", port: 9 });
|
||||
socket.once("error", reject);
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve({ connected: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
if (input.mode === "exec") return { stdout: execFileSync("/bin/echo", ["escaped"], { encoding: "utf8" }) };
|
||||
if (input.mode === "io") return { read: runtimeReadTextFileSync("/etc/hostname") };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** 单条请求/响应包括 base64 的上限;任务自己的解压条目和文件大小限制另计。 */
|
||||
export const SANDBOX_MESSAGE_BYTES = 128 * 1024 * 1024;
|
||||
@@ -0,0 +1,107 @@
|
||||
#define _GNU_SOURCE
|
||||
#include <node_api.h>
|
||||
#include <errno.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <linux/audit.h>
|
||||
#include <linux/filter.h>
|
||||
#include <linux/seccomp.h>
|
||||
#include <sched.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#if defined(__x86_64__)
|
||||
#define SANDBOX_ARCH AUDIT_ARCH_X86_64
|
||||
#elif defined(__aarch64__)
|
||||
#define SANDBOX_ARCH AUDIT_ARCH_AARCH64
|
||||
#else
|
||||
#error Unsupported sandbox architecture
|
||||
#endif
|
||||
#define ALLOW(n) BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, __NR_##n, 0, 1), BPF_STMT(BPF_RET|BPF_K, SECCOMP_RET_ALLOW)
|
||||
|
||||
/* Default-deny policy: no path-based IO, sockets, exec, ptrace, mounts,
|
||||
* io_uring, or child processes. Apply to every existing runtime thread (TSYNC).
|
||||
* New JS engine threads inherit the policy; only CLONE_THREAD is allowed. */
|
||||
static napi_value lockdown(napi_env env, napi_callback_info info) {
|
||||
struct sock_filter filter[] = {
|
||||
BPF_STMT(BPF_LD|BPF_W|BPF_ABS, offsetof(struct seccomp_data, arch)),
|
||||
BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, SANDBOX_ARCH, 1, 0),
|
||||
BPF_STMT(BPF_RET|BPF_K, SECCOMP_RET_KILL_PROCESS),
|
||||
BPF_STMT(BPF_LD|BPF_W|BPF_ABS, offsetof(struct seccomp_data, nr)),
|
||||
ALLOW(read), ALLOW(write), ALLOW(readv), ALLOW(writev), ALLOW(close),
|
||||
ALLOW(fstat), ALLOW(lseek), ALLOW(fcntl),
|
||||
ALLOW(mmap), ALLOW(mprotect), ALLOW(munmap), ALLOW(mremap), ALLOW(brk), ALLOW(madvise),
|
||||
ALLOW(rt_sigaction), ALLOW(rt_sigprocmask), ALLOW(rt_sigreturn), ALLOW(sigaltstack),
|
||||
ALLOW(futex), ALLOW(sched_yield), ALLOW(sched_getaffinity),
|
||||
ALLOW(clock_gettime), ALLOW(clock_nanosleep), ALLOW(nanosleep), ALLOW(gettimeofday),
|
||||
ALLOW(getpid), ALLOW(gettid), ALLOW(getuid), ALLOW(geteuid), ALLOW(getgid), ALLOW(getegid),
|
||||
ALLOW(getrandom), ALLOW(getrusage), ALLOW(prlimit64),
|
||||
ALLOW(epoll_create1), ALLOW(epoll_ctl), ALLOW(epoll_pwait),
|
||||
#ifdef __NR_epoll_wait
|
||||
ALLOW(epoll_wait),
|
||||
#endif
|
||||
#ifdef __NR_epoll_pwait2
|
||||
ALLOW(epoll_pwait2),
|
||||
#endif
|
||||
ALLOW(eventfd2), ALLOW(pipe2), ALLOW(set_robust_list), ALLOW(rseq),
|
||||
#ifdef __NR_arch_prctl
|
||||
ALLOW(arch_prctl),
|
||||
#endif
|
||||
ALLOW(exit), ALLOW(exit_group),
|
||||
/* clone3 cannot be safely inspected through its pointer argument. ENOSYS
|
||||
* asks libc to use clone, whose thread flag we can inspect. */
|
||||
#ifdef __NR_clone3
|
||||
BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, __NR_clone3, 0, 1),
|
||||
BPF_STMT(BPF_RET|BPF_K, SECCOMP_RET_ERRNO|ENOSYS),
|
||||
#endif
|
||||
BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, __NR_clone, 0, 3),
|
||||
BPF_STMT(BPF_LD|BPF_W|BPF_ABS, offsetof(struct seccomp_data, args[0])),
|
||||
BPF_JUMP(BPF_JMP|BPF_JSET|BPF_K, CLONE_THREAD, 0, 1),
|
||||
BPF_STMT(BPF_RET|BPF_K, SECCOMP_RET_ALLOW),
|
||||
BPF_STMT(BPF_RET|BPF_K, SECCOMP_RET_ERRNO|EPERM),
|
||||
};
|
||||
struct sock_fprog program = { .len = sizeof(filter)/sizeof(filter[0]), .filter = filter };
|
||||
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0 ||
|
||||
syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_TSYNC, &program) != 0) {
|
||||
napi_throw_error(env, "SANDBOX_POLICY", "Cannot install sandbox seccomp policy");
|
||||
return NULL;
|
||||
}
|
||||
/* Verify the installed kernel policy directly. Compiled Bun's network
|
||||
* wrappers can normalize EPERM to ECONNREFUSED, so JS error strings alone
|
||||
* cannot prove that the underlying syscall was blocked. */
|
||||
errno = 0;
|
||||
long fd = syscall(SYS_socket, AF_INET, SOCK_STREAM, 0);
|
||||
if (fd >= 0 || errno != EPERM) {
|
||||
if (fd >= 0) close((int)fd);
|
||||
napi_throw_error(env, "SANDBOX_POLICY", "Sandbox did not deny socket creation");
|
||||
return NULL;
|
||||
}
|
||||
errno = 0;
|
||||
fd = syscall(SYS_openat, AT_FDCWD, "/__umt_sandbox_policy_probe__", O_RDONLY, 0);
|
||||
if (fd >= 0 || errno != EPERM) {
|
||||
if (fd >= 0) close((int)fd);
|
||||
napi_throw_error(env, "SANDBOX_POLICY", "Sandbox did not deny file access");
|
||||
return NULL;
|
||||
}
|
||||
errno = 0;
|
||||
char *argv[] = { "/__umt_sandbox_policy_probe__", NULL };
|
||||
char *envp[] = { NULL };
|
||||
syscall(SYS_execve, argv[0], argv, envp);
|
||||
if (errno != EPERM) {
|
||||
napi_throw_error(env, "SANDBOX_POLICY", "Sandbox did not deny execve");
|
||||
return NULL;
|
||||
}
|
||||
napi_value out;
|
||||
napi_get_undefined(env, &out);
|
||||
return out;
|
||||
}
|
||||
static napi_value init(napi_env env, napi_value exports) {
|
||||
napi_value fn;
|
||||
napi_create_function(env, "lockdown", NAPI_AUTO_LENGTH, lockdown, NULL, &fn);
|
||||
napi_set_named_property(env, exports, "lockdown", fn);
|
||||
return exports;
|
||||
}
|
||||
NAPI_MODULE(NODE_GYP_MODULE_NAME, init)
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** Load before lockdown; compiled deployments install the policy next to the binary. */
|
||||
export function installSandboxPolicy(): void {
|
||||
if (process.platform !== "linux") throw new Error("解析沙箱需要 Linux seccomp");
|
||||
const compiled = !/^bun(?:\.exe)?$/.test(process.execPath.split("/").at(-1) ?? "");
|
||||
const path = compiled
|
||||
? resolve(dirname(process.execPath), "sandbox-policy.node")
|
||||
: fileURLToPath(new URL("../../../../dist/sandbox-policy.node", import.meta.url));
|
||||
const policy = createRequire(import.meta.url)(path) as { lockdown(): void };
|
||||
policy.lockdown();
|
||||
}
|
||||
@@ -1,33 +1,31 @@
|
||||
// 沙箱子进程入口:先加载任务表,再撤销全部权限,然后从 stdin 读一条 {job, input},把 {ok, result | error} 写到 stdout。
|
||||
// 开发 / 测试由 deno run 直接跑本文件;编译二进制以 `<自身> --role sandbox` 拉起(main.ts 在加载其余模块前分派)。
|
||||
import { decodeWorkerRequest, readWorkerRequestText, writeWorkerMessage } from "@umt/runtime/worker";
|
||||
import { prepareJobs, SANDBOX_JOBS } from "./jobs.ts";
|
||||
import { SANDBOX_MESSAGE_BYTES } from "./limits.ts";
|
||||
import { installSandboxPolicy } from "./policy.ts";
|
||||
|
||||
const PERMISSIONS = ["read", "write", "net", "env", "run", "ffi", "sys"] as const;
|
||||
|
||||
async function writeAll(bytes: Uint8Array) {
|
||||
let off = 0;
|
||||
while (off < bytes.length) off += await Deno.stdout.write(bytes.subarray(off));
|
||||
}
|
||||
|
||||
// 预加载可信任务表和 WASM,有界读取 stdin,安装策略后才 JSON.parse 与处理输入。
|
||||
export async function sandboxWorkerMain(): Promise<void> {
|
||||
for (const key of Object.keys(process.env)) if (key !== "PATH") delete process.env[key];
|
||||
await prepareJobs();
|
||||
for (const name of PERMISSIONS) {
|
||||
try {
|
||||
Deno.permissions.revokeSync({ name } as Deno.PermissionDescriptor);
|
||||
} catch {
|
||||
// 运行时不认识的权限名:跳过
|
||||
}
|
||||
}
|
||||
let out: unknown;
|
||||
const requestText = await readWorkerRequestText(SANDBOX_MESSAGE_BYTES);
|
||||
installSandboxPolicy();
|
||||
const req = decodeWorkerRequest(requestText);
|
||||
try {
|
||||
const req = JSON.parse(await new Response(Deno.stdin.readable).text()) as { job: string; input: unknown };
|
||||
const fn = (SANDBOX_JOBS as Record<string, (i: unknown) => unknown>)[req.job];
|
||||
if (!fn) throw new Error(`没有这个沙箱任务:${req.job}`);
|
||||
out = { ok: true, result: await fn(req.input) };
|
||||
} catch (e) {
|
||||
out = { ok: false, error: `${(e as Error).name}: ${(e as Error).message}`.slice(0, 2000) };
|
||||
const fn = (SANDBOX_JOBS as Record<string, (i: unknown) => unknown>)[req.task];
|
||||
if (!Object.hasOwn(SANDBOX_JOBS, req.task) || typeof fn !== "function")
|
||||
throw new Error(`没有这个沙箱任务:${req.task}`);
|
||||
const result = await fn(req.input);
|
||||
writeWorkerMessage({ id: req.id, type: "result", result }, SANDBOX_MESSAGE_BYTES);
|
||||
} catch (error) {
|
||||
writeWorkerMessage(
|
||||
{
|
||||
id: req.id,
|
||||
type: "error",
|
||||
error: { code: "JOB", message: `${(error as Error).name}: ${(error as Error).message}`.slice(0, 2000) },
|
||||
},
|
||||
SANDBOX_MESSAGE_BYTES,
|
||||
);
|
||||
}
|
||||
await writeAll(new TextEncoder().encode(`${JSON.stringify(out)}\n`));
|
||||
}
|
||||
|
||||
if (import.meta.main) await sandboxWorkerMain();
|
||||
@@ -5,7 +5,8 @@ import { assertLocalPaths } from "@umt/adapters";
|
||||
// 参数是 Alist 虚拟路径(/@alist/…)就抛 FsError("REMOTE_PATH_ON_LOCAL_FS"),不在本机建出 /@alist 目录或写进文件。
|
||||
// 读类函数(stat / readdir / readFile …)原样转出:对虚拟路径只会 ENOENT。
|
||||
|
||||
export * from "node:fs/promises";
|
||||
// 显式导出避免 Bun 对内置模块 export * 的编译问题。
|
||||
export const { stat, lstat, readdir, readFile, realpath } = fsp;
|
||||
|
||||
type P = Parameters<typeof fsp.mkdir>[0];
|
||||
const s = (p: unknown) => (typeof p === "string" ? p : p instanceof URL ? p.pathname : undefined);
|
||||
|
||||
@@ -250,7 +250,7 @@ export async function vreadFile(p: string, maxBytes = 4 * 1024 * 1024): Promise<
|
||||
|
||||
/** 本机临时目录(远端写入的本地中转) */
|
||||
export function relayDir(): string {
|
||||
return join(storageDataDir() ?? Deno.env.get("DATA_DIR") ?? tmpdir(), "cache", "storage-relay");
|
||||
return join(storageDataDir() ?? process.env.DATA_DIR ?? tmpdir(), "cache", "storage-relay");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DEFAULT_WORKER_LANES, WORKER_LANE_MAX, type WorkerLaneSettings } from "@umt/contracts";
|
||||
import { runtimeUnrefTimer } from "@umt/runtime";
|
||||
import { laneOf, type QueueCounts, type TaskDefinition, type TaskLane, type WorkerHandle } from "@umt/tasks";
|
||||
import type { Logger } from "pino";
|
||||
import type { Config } from "../config.ts";
|
||||
@@ -167,7 +168,7 @@ export class LaneController {
|
||||
if (!this.timer) {
|
||||
this.timer = setInterval(() => void this.refresh(), this.o.refreshMs ?? 5000);
|
||||
try {
|
||||
Deno.unrefTimer(this.timer);
|
||||
runtimeUnrefTimer(this.timer);
|
||||
} catch {
|
||||
// 非 Deno 运行时
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||
import { submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
|
||||
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { applyEpisodeTotal } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
|
||||
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 300_000;
|
||||
|
||||
describe.skipIf(!hasPg)("自动洗版物理替换(真实 PG、qB协议与临时文件)", () => {
|
||||
let h: Harness;
|
||||
let qb: MockQb;
|
||||
let lib: TempTree;
|
||||
let cache: TempTree;
|
||||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||
let groupId: string;
|
||||
const disk = fakeDisk(100_000_000_000);
|
||||
const torrents = new Map<string, Uint8Array>();
|
||||
let pieceSeed = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-auto-up-lib-" });
|
||||
cache = await createTempTree({}, { prefix: "umt-auto-up-cache-" });
|
||||
disk.state.watch = [cache.root];
|
||||
disk.setPct(10);
|
||||
h = await createHarness("auto_upgrade_replace", {
|
||||
diskUsage: disk.fn,
|
||||
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
|
||||
});
|
||||
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
|
||||
await h.setSettings("naming", {
|
||||
movie: "{{title}} ({{year}})/{{title}} ({{year}}) - {{videoFormat}}{{fileExt}}",
|
||||
tv: "{{title}} ({{year}})/Season {{season}}/{{title}} - {{season_episode}} - {{videoFormat}}{{fileExt}}",
|
||||
});
|
||||
const g = await h.api("POST", "/rules", { name: "自动洗版优先REMUX", ruleString: "REMUX > WEBDL" });
|
||||
expect(g.status, JSON.stringify(g.body)).toBe(201);
|
||||
groupId = g.body.data.id;
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await qb?.close();
|
||||
await lib?.cleanup();
|
||||
await cache?.cleanup();
|
||||
});
|
||||
|
||||
async function tracked(kind: "movie" | "series", title: string, versions: { name: string; resolutions: string[] }[]) {
|
||||
const entityId = await seedEntity(h.db, { kind, title, year: 2024 });
|
||||
const sub = await h.db
|
||||
.insertInto("subscription")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
media_kind: kind,
|
||||
mode: "once",
|
||||
season_scope: "current_season",
|
||||
params: JSON.stringify({ autoFill: "on" }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const slotIds: string[] = [];
|
||||
for (const [position, v] of versions.entries()) {
|
||||
const slot = await h.db
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
subscription_id: sub.id,
|
||||
name: v.name,
|
||||
position,
|
||||
hard_conditions: JSON.stringify({ resolutions: v.resolutions }),
|
||||
soft_preferences: JSON.stringify({ groups: ["GOOD", "OTHER", "LOW"] }),
|
||||
upgrade_threshold: JSON.stringify({
|
||||
enabled: true,
|
||||
basis: "priority",
|
||||
mode: kind === "movie" ? "movie" : "per_unit",
|
||||
...(kind === "series" ? { consistency: "season_release" } : {}),
|
||||
}),
|
||||
rule_group_ids: JSON.stringify([groupId]),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
slotIds.push(slot.id);
|
||||
}
|
||||
if (kind === "series")
|
||||
await h.db
|
||||
.transaction()
|
||||
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 2, now: new Date() }));
|
||||
return { entityId, slotIds };
|
||||
}
|
||||
|
||||
async function release(entityId: string, title: string) {
|
||||
const t = makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: ++pieceSeed });
|
||||
const id = await seedRelease(h.db, { siteId: base.siteId, entityId, title, size: SIZE, hash: t.hash });
|
||||
torrents.set(id, t.bytes);
|
||||
return { id, hash: t.hash };
|
||||
}
|
||||
|
||||
async function acquire(entityId: string, title: string, expectedSlot: string, purpose: "fill" | "upgrade") {
|
||||
const rel = await release(entityId, title);
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
|
||||
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
|
||||
const planId = ev.plans[0]!;
|
||||
const plan = await h.db
|
||||
.selectFrom("acquisition_plan")
|
||||
.selectAll()
|
||||
.where("id", "=", planId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(plan).toMatchObject({ slot_id: expectedSlot, purpose });
|
||||
if (purpose === "upgrade") expect(plan.evaluation).toMatchObject({ automaticReplacement: true });
|
||||
const submitted = await submitDownload(h.d, planId);
|
||||
expect(submitted.status, JSON.stringify(submitted)).toBe("submitted");
|
||||
await qb.complete(rel.hash, { writeFiles: true });
|
||||
expect((await syncDownloads(h.d)).completed).toContain(submitted.downloadTaskId);
|
||||
const imported = await planImport(h.d, {
|
||||
downloadTaskId: submitted.downloadTaskId!,
|
||||
trigger: "auto",
|
||||
actorType: "system",
|
||||
});
|
||||
expect(imported.status, JSON.stringify(imported)).toBe("running");
|
||||
expect((await executeRecord(h.d, imported.recordId!)).status).toBe("succeeded");
|
||||
const result = await h.db
|
||||
.selectFrom("organization_file_result")
|
||||
.select("media_asset_id")
|
||||
.where("organization_record_id", "=", imported.recordId!)
|
||||
.where("role", "=", "main")
|
||||
.where("status", "=", "succeeded")
|
||||
.executeTakeFirstOrThrow();
|
||||
const location = await h.db
|
||||
.selectFrom("asset_location")
|
||||
.select("relative_path")
|
||||
.where("media_asset_id", "=", result.media_asset_id!)
|
||||
.where("location_kind", "=", "library")
|
||||
.where("status", "=", "available")
|
||||
.executeTakeFirstOrThrow();
|
||||
const bytes = await readFile(lib.path(location.relative_path));
|
||||
expect(bytes).toHaveLength(SIZE);
|
||||
return { assetId: result.media_asset_id!, path: location.relative_path, bytes, recordId: imported.recordId! };
|
||||
}
|
||||
|
||||
async function expectKept(file: Awaited<ReturnType<typeof acquire>>) {
|
||||
const a = await h.db
|
||||
.selectFrom("media_asset")
|
||||
.select("status")
|
||||
.where("id", "=", file.assetId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(a.status).toBe("active");
|
||||
expect((await readFile(lib.path(file.path))).equals(file.bytes)).toBe(true);
|
||||
expect(
|
||||
await h.db.selectFrom("trash_item").select("id").where("original_path", "=", file.path).execute(),
|
||||
).toHaveLength(0);
|
||||
}
|
||||
|
||||
async function expectTrashed(file: Awaited<ReturnType<typeof acquire>>, samePath = false) {
|
||||
const a = await h.db
|
||||
.selectFrom("media_asset")
|
||||
.select("status")
|
||||
.where("id", "=", file.assetId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(a.status).toBe("retired");
|
||||
const oldLocation = await h.db
|
||||
.selectFrom("asset_location")
|
||||
.select("status")
|
||||
.where("media_asset_id", "=", file.assetId)
|
||||
.where("location_kind", "=", "library")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(oldLocation.status).toBe("removed");
|
||||
if (!samePath) expect(existsSync(lib.path(file.path))).toBe(false);
|
||||
const items = await h.db
|
||||
.selectFrom("trash_item")
|
||||
.select("trash_path")
|
||||
.where("original_path", "=", file.path)
|
||||
.execute();
|
||||
expect(items).toHaveLength(1);
|
||||
expect((await readFile(items[0]!.trash_path)).equals(file.bytes)).toBe(true);
|
||||
}
|
||||
|
||||
it("电影低档自动入库后升级替换同路径旧文件并回收,另一保留分辨率保持原字节", async () => {
|
||||
const f = await tracked("movie", "Auto Movie", [
|
||||
{ name: "HD", resolutions: ["1080p"] },
|
||||
{ name: "UHD", resolutions: ["2160p"] },
|
||||
]);
|
||||
const uhd = await acquire(f.entityId, "Auto.Movie.2024.2160p.BluRay.REMUX.HEVC-GOOD", f.slotIds[1]!, "fill");
|
||||
const lower = await acquire(f.entityId, "Auto.Movie.2024.1080p.WEB-DL.x264-LOW", f.slotIds[0]!, "fill");
|
||||
const higher = await acquire(f.entityId, "Auto.Movie.2024.1080p.BluRay.REMUX.AVC-GOOD", f.slotIds[0]!, "upgrade");
|
||||
expect(higher.path).toBe(lower.path);
|
||||
expect(higher.assetId).not.toBe(lower.assetId);
|
||||
expect(higher.bytes.equals(lower.bytes)).toBe(false);
|
||||
await expectTrashed(lower, true);
|
||||
await expectKept(uhd);
|
||||
expect((await readFile(lib.path(higher.path))).equals(higher.bytes)).toBe(true);
|
||||
await executeRecord(h.d, higher.recordId);
|
||||
await expectTrashed(lower, true);
|
||||
await expectKept(uhd);
|
||||
}, 60_000);
|
||||
|
||||
it("旧单文件E1-E2在同套新E1入库时保留,新E2补齐后自动回收旧合集", async () => {
|
||||
const f = await tracked("series", "Auto Merged", [{ name: "单保留", resolutions: ["720p", "1080p"] }]);
|
||||
const old = await acquire(f.entityId, "Auto.Merged.S01E01-E02.720p.WEB-DL.x264-LOW", f.slotIds[0]!, "fill");
|
||||
const first = await acquire(f.entityId, "Auto.Merged.S01E01.1080p.BluRay.REMUX.AVC-GOOD", f.slotIds[0]!, "upgrade");
|
||||
await expectKept(old);
|
||||
const second = await acquire(
|
||||
f.entityId,
|
||||
"Auto.Merged.S01E02.1080p.BluRay.REMUX.AVC-GOOD",
|
||||
f.slotIds[0]!,
|
||||
"upgrade",
|
||||
);
|
||||
await expectTrashed(old);
|
||||
await expectKept(first);
|
||||
await expectKept(second);
|
||||
await executeRecord(h.d, second.recordId);
|
||||
await expectTrashed(old);
|
||||
}, 60_000);
|
||||
|
||||
it("不同组或另一保留分辨率的已入库E2不能凑齐新E1的合集覆盖", async () => {
|
||||
const f = await tracked("series", "Auto Isolated", [
|
||||
{ name: "HD", resolutions: ["1080p"] },
|
||||
{ name: "UHD", resolutions: ["2160p"] },
|
||||
]);
|
||||
const old = await acquire(f.entityId, "Auto.Isolated.S01E01-E02.1080p.WEB-DL.x264-LOW", f.slotIds[0]!, "fill");
|
||||
const otherResolution = await acquire(
|
||||
f.entityId,
|
||||
"Auto.Isolated.S01E02.2160p.BluRay.REMUX.AVC-GOOD",
|
||||
f.slotIds[1]!,
|
||||
"fill",
|
||||
);
|
||||
const otherGroup = await acquire(
|
||||
f.entityId,
|
||||
"Auto.Isolated.S01E02.1080p.BluRay.REMUX.AVC-OTHER",
|
||||
f.slotIds[0]!,
|
||||
"upgrade",
|
||||
);
|
||||
await expectKept(old);
|
||||
const first = await acquire(
|
||||
f.entityId,
|
||||
"Auto.Isolated.S01E01.1080p.BluRay.REMUX.AVC-GOOD",
|
||||
f.slotIds[0]!,
|
||||
"upgrade",
|
||||
);
|
||||
await expectKept(old);
|
||||
await expectKept(otherGroup);
|
||||
await expectKept(otherResolution);
|
||||
const second = await acquire(
|
||||
f.entityId,
|
||||
"Auto.Isolated.S01E02.1080p.BluRay.REMUX.AVC-GOOD",
|
||||
f.slotIds[0]!,
|
||||
"upgrade",
|
||||
);
|
||||
await expectTrashed(old);
|
||||
await expectTrashed(otherGroup, true);
|
||||
await expectKept(first);
|
||||
await expectKept(otherResolution);
|
||||
expect((await readFile(lib.path(second.path))).equals(second.bytes)).toBe(true);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AcquisitionPolicyScope, SlotTemplate, SubscriptionParams } from "@umt/contracts";
|
||||
import type { AcquisitionSlotRow } from "@umt/db";
|
||||
import { type QualityFacts, type SlotSoftPreferences, type SlotSpec, seasonReleaseKey } from "@umt/domain";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { ensureSubscriptionSlot } from "../../src/modules/acquisition-organize/services/collection-strategy.ts";
|
||||
import { seasonReleaseTarget } from "../../src/modules/acquisition-organize/services/season-release.ts";
|
||||
import { slotComplete } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
|
||||
import { applySlotPreset } from "../../src/modules/acquisition-organize/services/subscriptions-params.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedEntity, seedRelease } from "./harness.ts";
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
describe.skipIf(!hasPg)("收录策略与成套洗版边界(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
let siteId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness("collection_strategy");
|
||||
siteId = (
|
||||
await h.db
|
||||
.insertInto("site")
|
||||
.values({
|
||||
key: "collection-test",
|
||||
name: "收录策略测试站",
|
||||
adapter: "nexusphp",
|
||||
base_url: "https://pt.example.test",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
async function policy(scope: AcquisitionPolicyScope, slots: SlotTemplate[], publishedAt = "2026-01-01T00:00:00Z") {
|
||||
return (
|
||||
await h.db
|
||||
.insertInto("policy_revision")
|
||||
.values({
|
||||
policy_key: randomUUID(),
|
||||
revision: 1,
|
||||
kind: "acquisition",
|
||||
status: "published",
|
||||
title: "收录策略",
|
||||
scope: JSON.stringify(scope),
|
||||
rules: JSON.stringify({ defaultSlots: slots }),
|
||||
published_at: new Date(publishedAt),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
}
|
||||
|
||||
async function subscription(
|
||||
kind: "movie" | "series",
|
||||
policyId: string | null = null,
|
||||
params: SubscriptionParams = {},
|
||||
) {
|
||||
const entityId = await seedEntity(h.db, { kind, title: `Collection ${randomUUID()}`, year: 2026 });
|
||||
const sub = await h.db
|
||||
.insertInto("subscription")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
media_kind: kind,
|
||||
mode: "once",
|
||||
season_scope: "current_season",
|
||||
policy_revision_id: policyId,
|
||||
params: JSON.stringify(params),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
return { entityId, sub };
|
||||
}
|
||||
|
||||
const slotsOf = (subId: string) =>
|
||||
h.db.selectFrom("acquisition_slot").selectAll().where("subscription_id", "=", subId).orderBy("position").execute();
|
||||
|
||||
async function series(opts: { resolutions?: string[]; wash?: boolean; soft?: SlotSoftPreferences } = {}) {
|
||||
const { entityId, sub } = await subscription("series");
|
||||
await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({ media_entity_id: entityId, kind: "season", season_number: 1, status: "confirmed", episode_total: 2 })
|
||||
.execute();
|
||||
const eps = new Map<number, string>();
|
||||
for (const ep of [1, 2]) {
|
||||
const unit = await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
kind: "episode",
|
||||
season_number: 1,
|
||||
episode_number: ep,
|
||||
status: "confirmed",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
eps.set(ep, unit.id);
|
||||
}
|
||||
const slot = await h.db
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
subscription_id: sub.id,
|
||||
name: "保留版本",
|
||||
hard_conditions: JSON.stringify({ resolutions: opts.resolutions ?? ["1080p"] }),
|
||||
soft_preferences: JSON.stringify(opts.soft ?? {}),
|
||||
upgrade_threshold: JSON.stringify(
|
||||
opts.wash === false
|
||||
? {}
|
||||
: { enabled: true, basis: "priority", consistency: "season_release", targetPri: 100 },
|
||||
),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
return { entityId, sub, slot, eps };
|
||||
}
|
||||
|
||||
const quality = (group: string, resolution: QualityFacts["resolution"] = "1080p"): QualityFacts => ({
|
||||
resolution,
|
||||
source: "WEB-DL",
|
||||
videoCodec: "H.264",
|
||||
releaseGroup: { name: group, source: "suffix" },
|
||||
hdr: [],
|
||||
pri: 100,
|
||||
});
|
||||
|
||||
async function asset(f: Awaited<ReturnType<typeof series>>, episodes: number[], q: QualityFacts, ledger = true) {
|
||||
const a = await h.db
|
||||
.insertInto("media_asset")
|
||||
.values({
|
||||
media_entity_id: f.entityId,
|
||||
kind: "video",
|
||||
status: "active",
|
||||
media_info: JSON.stringify({ _umt: { quality: q } }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
for (const ep of episodes) {
|
||||
await h.db
|
||||
.insertInto("asset_unit_coverage")
|
||||
.values({ media_asset_id: a.id, content_unit_id: f.eps.get(ep)!, coverage: "full" })
|
||||
.execute();
|
||||
if (ledger)
|
||||
await h.db
|
||||
.insertInto("slot_unit")
|
||||
.values({
|
||||
slot_id: f.slot.id,
|
||||
content_unit_id: f.eps.get(ep)!,
|
||||
media_asset_id: a.id,
|
||||
quality: JSON.stringify(q),
|
||||
pri: q.pri,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc
|
||||
.columns(["slot_id", "content_unit_id"])
|
||||
.doUpdateSet({ media_asset_id: a.id, quality: JSON.stringify(q), pri: q.pri }),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
return a.id;
|
||||
}
|
||||
|
||||
async function plan(
|
||||
slot: AcquisitionSlotRow,
|
||||
q: QualityFacts,
|
||||
units: string[],
|
||||
status: "submitted" | "reserved" = "submitted",
|
||||
) {
|
||||
const relId = await seedRelease(h.db, {
|
||||
siteId,
|
||||
entityId: slot.media_entity_id,
|
||||
title: `Collection.S01.${q.resolution}.WEB-DL.x264-${q.releaseGroup!.name}`,
|
||||
size: 1_000_000,
|
||||
hash: randomUUID().replaceAll("-", "").padEnd(40, "0"),
|
||||
});
|
||||
const p = await h.db
|
||||
.insertInto("acquisition_plan")
|
||||
.values({
|
||||
slot_id: slot.id,
|
||||
source_release_id: relId,
|
||||
status,
|
||||
status_reason: status === "submitted" ? "download_accepted" : null,
|
||||
purpose: "upgrade",
|
||||
unit_keys: units,
|
||||
payload_hash: randomUUID(),
|
||||
idempotency_key: randomUUID(),
|
||||
evaluation: JSON.stringify({ pri: q.pri, quality: q, season: 1, seasonRelease: seasonReleaseKey(q) }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return { planId: p.id, relId };
|
||||
}
|
||||
|
||||
it("显式选择优先;分类按 movie/tv 匹配,未命中分类时使用媒体类型默认模板", async () => {
|
||||
const allId = await policy({}, [{ name: "通用" }], "2026-06-01T00:00:00Z");
|
||||
const movieId = await policy({ mediaKinds: ["movie"] }, [{ name: "电影默认" }]);
|
||||
const tvId = await policy({ mediaKinds: ["series"] }, [{ name: "剧集默认" }]);
|
||||
const movieCategoryId = await policy({ categories: { movie: ["精选"] } }, [{ name: "电影精选" }]);
|
||||
const tvCategoryId = await policy({ categories: { tv: ["精选"] } }, [{ name: "剧集精选" }]);
|
||||
for (const [kind, category, explicitId, expectedId, name] of [
|
||||
["series", "精选", allId, allId, "通用"],
|
||||
["movie", "精选", null, movieCategoryId, "电影精选"],
|
||||
["series", "精选", null, tvCategoryId, "剧集精选"],
|
||||
["movie", "其他", null, movieId, "电影默认"],
|
||||
["series", "其他", null, tvId, "剧集默认"],
|
||||
] as const) {
|
||||
const { sub } = await subscription(kind, explicitId);
|
||||
expect(await ensureSubscriptionSlot(h.db, sub, { mediaKind: kind, category })).toBe(1);
|
||||
expect(await slotsOf(sub.id)).toMatchObject([{ name, policy_revision_id: expectedId, position: 0 }]);
|
||||
expect(await ensureSubscriptionSlot(h.db, sub, { mediaKind: kind, category })).toBe(0);
|
||||
expect(await slotsOf(sub.id)).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("显式选择没有模板的策略时报告错误,不静默回退到分类或通用策略", async () => {
|
||||
const policyId = await policy({}, []);
|
||||
const { sub } = await subscription("series", policyId);
|
||||
await expect(ensureSubscriptionSlot(h.db, sub, { mediaKind: "series", category: "精选" })).rejects.toThrow(
|
||||
"所选收录策略",
|
||||
);
|
||||
expect(await slotsOf(sub.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("双分辨率模板保留各自范围,统一预设即使与某模板相同也不能覆盖它", async () => {
|
||||
const policyId = await policy({}, [
|
||||
{ name: "HD", hardConditions: { resolutions: ["1080p"] } },
|
||||
{ name: "UHD", hardConditions: { resolutions: ["2160p"] } },
|
||||
]);
|
||||
const { sub } = await subscription("series", policyId, { resolution: "1080p" });
|
||||
expect(await ensureSubscriptionSlot(h.db, sub)).toBe(2);
|
||||
const expected = [
|
||||
{ name: "HD", hard_conditions: { resolutions: ["1080p"] } },
|
||||
{ name: "UHD", hard_conditions: { resolutions: ["2160p"] } },
|
||||
];
|
||||
expect(await slotsOf(sub.id)).toMatchObject(expected);
|
||||
await applySlotPreset(h.db, {
|
||||
subscriptionId: sub.id,
|
||||
prev: { resolution: "1080p" },
|
||||
next: { resolution: "720p" },
|
||||
});
|
||||
expect(await slotsOf(sub.id)).toMatchObject(expected);
|
||||
});
|
||||
|
||||
it("单保留4K版本时,作品已有完整720p不能填补4K覆盖", async () => {
|
||||
const f = await series({ resolutions: ["2160p"], wash: false });
|
||||
await asset(f, [1, 2], quality("LOW", "720p"), false);
|
||||
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: false, atTarget: false });
|
||||
await asset(f, [1, 2], quality("UHD", "2160p"));
|
||||
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: true });
|
||||
});
|
||||
|
||||
it("已接收的旧720p计划同样不能填补改为4K后的范围", async () => {
|
||||
const f = await series({ resolutions: ["2160p"], wash: false });
|
||||
await plan(f.slot, quality("LOW", "720p"), ["s1e1", "s1e2"]);
|
||||
expect(await slotComplete(h.db, f.slot, "tv", [], { includeSubmitted: true })).toMatchObject({
|
||||
complete: false,
|
||||
atTarget: false,
|
||||
});
|
||||
await plan(f.slot, quality("UHD", "2160p"), ["s1e1", "s1e2"]);
|
||||
expect(await slotComplete(h.db, f.slot, "tv", [], { includeSubmitted: true })).toMatchObject({
|
||||
complete: true,
|
||||
atTarget: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("HD/UHD分别完成,另一个保留分辨率的资产不能借来填缺", async () => {
|
||||
const f = await series({ resolutions: ["1080p"], wash: false });
|
||||
const uhd = await h.db
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
media_entity_id: f.entityId,
|
||||
subscription_id: f.sub.id,
|
||||
name: "UHD",
|
||||
hard_conditions: JSON.stringify({ resolutions: ["2160p"] }),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await asset(f, [1, 2], quality("HD"));
|
||||
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true });
|
||||
expect(await slotComplete(h.db, uhd, "tv", [])).toMatchObject({ complete: false });
|
||||
await asset({ ...f, slot: uhd }, [1, 2], quality("UHD", "2160p"));
|
||||
expect(await slotComplete(h.db, uhd, "tv", [])).toMatchObject({ complete: true });
|
||||
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true });
|
||||
});
|
||||
|
||||
it("各集pri100但组不同仍未到成套目标;同组的分集资源合齐后到目标", async () => {
|
||||
const f = await series({ soft: { groups: ["B", "A"] } });
|
||||
await asset(f, [1], quality("B"));
|
||||
await asset(f, [2], quality("A"));
|
||||
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: false });
|
||||
await asset(f, [2], quality("B"));
|
||||
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: true });
|
||||
});
|
||||
|
||||
it("完整A不能盖过更优但只有部分集的B,成套洗版继续以B为目标", async () => {
|
||||
const f = await series({ soft: { groups: ["B", "A"] } });
|
||||
await asset(f, [1, 2], quality("A"));
|
||||
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: true });
|
||||
await asset(f, [1], quality("B"));
|
||||
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: false });
|
||||
await asset(f, [2], quality("B"));
|
||||
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: true });
|
||||
});
|
||||
|
||||
it("在途季包冻结版本事实,发布属性修改不漂移锚点,失败后才释放", async () => {
|
||||
const f = await series({ soft: { groups: ["C", "A", "B"] } });
|
||||
const frozen = quality("A");
|
||||
const p = await plan(f.slot, frozen, ["s1*"], "reserved");
|
||||
const spec: SlotSpec = {
|
||||
name: "成套目标",
|
||||
hard: { resolutions: ["1080p"] },
|
||||
soft: { groups: ["C", "A", "B"] },
|
||||
upgrade: { enabled: true, basis: "priority", consistency: "season_release" },
|
||||
};
|
||||
expect(seasonReleaseKey((await seasonReleaseTarget(h.db, f.slot, 1, spec, quality("B")))!)).toBe(
|
||||
seasonReleaseKey(frozen),
|
||||
);
|
||||
await h.db
|
||||
.updateTable("source_release")
|
||||
.set({ title: "Collection.S01.1080p.WEB-DL.x264-C", parsed: JSON.stringify({}), parser_version: null })
|
||||
.where("id", "=", p.relId)
|
||||
.execute();
|
||||
expect(seasonReleaseKey((await seasonReleaseTarget(h.db, f.slot, 1, spec, quality("B")))!)).toBe(
|
||||
seasonReleaseKey(frozen),
|
||||
);
|
||||
await h.db.updateTable("acquisition_plan").set({ status: "failed" }).where("id", "=", p.planId).execute();
|
||||
expect(seasonReleaseKey((await seasonReleaseTarget(h.db, f.slot, 1, spec, quality("B")))!)).toBe(
|
||||
seasonReleaseKey(quality("B")),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { createQbClient } from "@umt/adapters";
|
||||
import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { MISSING_CONFIRM_ROUNDS, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
|
||||
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase } from "./harness.ts";
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe.skipIf(!hasPg)("下载观测顺序(真实 PG + 受控 qB 响应)", () => {
|
||||
let h: Harness;
|
||||
let qb: MockQb;
|
||||
let lib: TempTree;
|
||||
let cache: TempTree;
|
||||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||
let tick = 0;
|
||||
let hashIndex = 0;
|
||||
const epoch = Date.parse("2026-10-13T00:00:00Z");
|
||||
let pending: {
|
||||
method: "list" | "findByHash";
|
||||
hash?: string;
|
||||
captured: ReturnType<typeof deferred>;
|
||||
resume: ReturnType<typeof deferred>;
|
||||
} | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
qb = await createMockQb();
|
||||
lib = await createTempTree({}, { sentinel: true });
|
||||
cache = await createTempTree({});
|
||||
h = await createHarness("download_order", { diskUsage: fakeDisk(10_000_000_000).fn });
|
||||
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
|
||||
h.d.ports.now = () => new Date(epoch + tick);
|
||||
const original = h.d.ports.downloader;
|
||||
h.d.ports.downloader = async (inst) => {
|
||||
const client = await original(inst);
|
||||
const hold = async <T>(method: "list" | "findByHash", read: () => Promise<T>, hash?: string): Promise<T> => {
|
||||
const gate = pending?.method === method && (!pending.hash || pending.hash === hash) ? pending : null;
|
||||
if (gate) pending = null;
|
||||
const snapshot = await read();
|
||||
if (gate) {
|
||||
gate.captured.resolve();
|
||||
await gate.resume.promise;
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
return {
|
||||
...client,
|
||||
list: (input) => hold("list", () => client.list(input)),
|
||||
findByHash: (hash) => hold("findByHash", () => client.findByHash(hash), hash),
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
pending?.resume.resolve();
|
||||
await h?.close();
|
||||
await qb?.close();
|
||||
await lib?.cleanup();
|
||||
await cache?.cleanup();
|
||||
});
|
||||
|
||||
const nextTime = () => new Date(epoch + ++tick);
|
||||
const row = (id: string) =>
|
||||
h.db.selectFrom("download_task").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
const pauseNext = (method: "list" | "findByHash", hash?: string) => {
|
||||
const gate = { method, hash, captured: deferred(), resume: deferred() };
|
||||
pending = gate;
|
||||
return gate;
|
||||
};
|
||||
const seedTorrent = (hash: string) => {
|
||||
qb.seed({ hash, name: `Observation.${hash}.mkv`, size: 300_000, tags: ["umt"], save_path: cache.root });
|
||||
qb.setProgress(hash, 0.25);
|
||||
};
|
||||
async function task() {
|
||||
const hash = (++hashIndex).toString(16).padStart(40, "0");
|
||||
seedTorrent(hash);
|
||||
nextTime();
|
||||
await syncDownloads(h.d);
|
||||
const r = await h.db
|
||||
.selectFrom("download_task")
|
||||
.selectAll()
|
||||
.where("torrent_hash", "=", hash)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(r).toMatchObject({ managed: true, status: "downloading", missing_count: 0 });
|
||||
return { hash, id: r.id };
|
||||
}
|
||||
|
||||
it("移除标签的新观测先提交时,旧有标签列表晚到不能复活managed", async () => {
|
||||
const t = await task();
|
||||
const gate = pauseNext("list");
|
||||
nextTime();
|
||||
const older = syncDownloads(h.d);
|
||||
try {
|
||||
await gate.captured.promise;
|
||||
qb.get(t.hash)!.tags = [];
|
||||
const newerAt = nextTime();
|
||||
await syncDownloads(h.d);
|
||||
expect(await row(t.id)).toMatchObject({ managed: false, tags: [], last_synced_at: newerAt });
|
||||
gate.resume.resolve();
|
||||
await older;
|
||||
expect(await row(t.id)).toMatchObject({ managed: false, tags: [], removed_at: null, last_synced_at: newerAt });
|
||||
} finally {
|
||||
gate.resume.resolve();
|
||||
await older;
|
||||
}
|
||||
});
|
||||
|
||||
it("旧缺失响应晚到不能把已经重新观测到的任务判移除或恢复旧missing_count", async () => {
|
||||
const t = await task();
|
||||
await h.db
|
||||
.updateTable("download_task")
|
||||
.set({ missing_count: MISSING_CONFIRM_ROUNDS - 1, missing_since: new Date(epoch) })
|
||||
.where("id", "=", t.id)
|
||||
.execute();
|
||||
const external = createQbClient({
|
||||
instanceId: base.instanceId,
|
||||
baseUrl: qb.url,
|
||||
username: "admin",
|
||||
password: "adminadmin",
|
||||
});
|
||||
await external.delete([t.hash], { deleteFiles: false });
|
||||
const gate = pauseNext("findByHash", t.hash);
|
||||
nextTime();
|
||||
const older = syncDownloads(h.d);
|
||||
try {
|
||||
await gate.captured.promise;
|
||||
seedTorrent(t.hash);
|
||||
const newerAt = nextTime();
|
||||
await syncDownloads(h.d);
|
||||
expect(await row(t.id)).toMatchObject({
|
||||
managed: true,
|
||||
status: "downloading",
|
||||
missing_count: 0,
|
||||
last_synced_at: newerAt,
|
||||
});
|
||||
gate.resume.resolve();
|
||||
await older;
|
||||
expect(await row(t.id)).toMatchObject({
|
||||
managed: true,
|
||||
status: "downloading",
|
||||
removed_at: null,
|
||||
missing_count: 0,
|
||||
missing_since: null,
|
||||
last_synced_at: newerAt,
|
||||
});
|
||||
} finally {
|
||||
gate.resume.resolve();
|
||||
await older;
|
||||
}
|
||||
});
|
||||
|
||||
it("重新贴标签的新观测可恢复管理,旧无标签响应晚到不能撤销恢复", async () => {
|
||||
const t = await task();
|
||||
qb.get(t.hash)!.tags = [];
|
||||
const gate = pauseNext("findByHash", t.hash);
|
||||
nextTime();
|
||||
const older = syncDownloads(h.d);
|
||||
try {
|
||||
await gate.captured.promise;
|
||||
qb.get(t.hash)!.tags = ["umt"];
|
||||
const newerAt = nextTime();
|
||||
await syncDownloads(h.d);
|
||||
gate.resume.resolve();
|
||||
await older;
|
||||
expect(await row(t.id)).toMatchObject({ managed: true, tags: ["umt"], last_synced_at: newerAt });
|
||||
qb.get(t.hash)!.tags = [];
|
||||
nextTime();
|
||||
await syncDownloads(h.d);
|
||||
expect(await row(t.id)).toMatchObject({ managed: false });
|
||||
qb.get(t.hash)!.tags = ["umt"];
|
||||
const restoredAt = nextTime();
|
||||
await syncDownloads(h.d);
|
||||
expect(await row(t.id)).toMatchObject({ managed: true, tags: ["umt"], last_synced_at: restoredAt });
|
||||
} finally {
|
||||
gate.resume.resolve();
|
||||
await older;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -262,6 +262,23 @@ describe.skipIf(!hasPg)("整理卡死回收(真实 PG + mock qB + 临时目录)"
|
||||
const dec = await decision(`organize_excluded:${plan.recordId}`);
|
||||
expect(dec).toMatchObject({ status: "open", subject_id: plan.recordId });
|
||||
expect(JSON.stringify(dec!.evidence)).toContain("excluded:episode_unresolved");
|
||||
// 终态重放也不能把部分成功当成整包成功,且刮削记录不重复。
|
||||
const scrapes = () =>
|
||||
h.db.selectFrom("scrape_record").select("id").where("organization_record_id", "=", plan.recordId!).execute();
|
||||
const beforeReplay = await scrapes();
|
||||
expect(beforeReplay).toHaveLength(1);
|
||||
expect((await executeRecord(h.d, plan.recordId!)).status).toBe("partial");
|
||||
expect(existsSync(join(cache.root, name, ani(40)))).toBe(true);
|
||||
expect(qb.get(hash)).toBeDefined();
|
||||
expect(qb.callsTo("torrents/delete")).toHaveLength(0);
|
||||
expect(
|
||||
await h.db
|
||||
.selectFrom("download_task")
|
||||
.select(["managed", "removed_at"])
|
||||
.where("id", "=", dtId)
|
||||
.executeTakeFirst(),
|
||||
).toEqual({ managed: true, removed_at: null });
|
||||
expect(await scrapes()).toEqual(beforeReplay);
|
||||
// partial 不在补移除范围内
|
||||
await h.db
|
||||
.updateTable("organization_record")
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AcquisitionPolicyRules, PolicyRevision, RuleGroup } from "@umt/contracts";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||
import { loadRuleCatalog } from "../../src/modules/acquisition-organize/services/rules.ts";
|
||||
import { slotSpecWithRules } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
|
||||
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
|
||||
|
||||
// 实际 PG + 路由/评估入口:模板引用、补齐与洗版组合,以及发布/删除时的引用保护。
|
||||
const pg = await pgAvailable();
|
||||
|
||||
describe.skipIf(!pg)("收录策略与规则组合", () => {
|
||||
let h: Harness;
|
||||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||
let n = 0;
|
||||
const disk = fakeDisk(100_000_000_000);
|
||||
|
||||
beforeAll(async () => {
|
||||
disk.setPct(10);
|
||||
h = await createHarness("policy_combinations", { diskUsage: disk.fn });
|
||||
base = await seedBase(h.db, {
|
||||
libraryPath: "/tmp/umt-policy-combinations-library",
|
||||
cachePath: "/tmp/umt-policy-combinations-cache",
|
||||
qbUrl: "http://127.0.0.1:9",
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const defaults = await h.api("PUT", "/rules/defaults", { acquisition: [], upgrade: [], search: [] });
|
||||
expect(defaults.status).toBe(200);
|
||||
await h.db
|
||||
.updateTable("policy_revision")
|
||||
.set({ status: "archived" })
|
||||
.where("status", "in", ["draft", "published"])
|
||||
.execute();
|
||||
await h.db.updateTable("acquisition_plan").set({ status: "cancelled" }).where("status", "=", "reserved").execute();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
async function group(ruleString = "H265", kind: "acquisition" | "upgrade" = "acquisition") {
|
||||
const response = await h.api("POST", "/rules", { name: `组合规则-${++n}`, kind, ruleString });
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(201);
|
||||
return response.body.data as RuleGroup;
|
||||
}
|
||||
|
||||
async function draft(rules: AcquisitionPolicyRules, policyKey = `combinations-${++n}`) {
|
||||
const response = await h.api("POST", "/policy-revisions", {
|
||||
policyKey,
|
||||
kind: "acquisition",
|
||||
title: "组合策略",
|
||||
rules,
|
||||
});
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(201);
|
||||
return response.body.data as PolicyRevision;
|
||||
}
|
||||
|
||||
async function publish(policy: PolicyRevision) {
|
||||
const response = await h.api("POST", `/policy-revisions/${policy.id}/publish`, {
|
||||
expectedRevision: policy.revision,
|
||||
});
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
return response.body.data as PolicyRevision;
|
||||
}
|
||||
|
||||
const release = (entityId: string, title: string) =>
|
||||
seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId,
|
||||
title,
|
||||
size: 300_000_000,
|
||||
hash: (++n).toString(16).padStart(40, "0"),
|
||||
});
|
||||
|
||||
it("策略预览执行模板的 H265 规则组,硬条件相同的 H264 资源被排除", async () => {
|
||||
const filter = await group();
|
||||
const entityId = await seedEntity(h.db, { kind: "movie", title: "Preview Film", year: 2024 });
|
||||
const h265 = await release(entityId, "Preview.Film.2024.1080p.WEB-DL.H265-GRP");
|
||||
const h264 = await release(entityId, "Preview.Film.2024.1080p.WEB-DL.H264-GRP");
|
||||
const policy = await draft({
|
||||
defaultSlots: [{ name: "只要 H265", hardConditions: { resolutions: ["1080p"] }, ruleGroupIds: [filter.id] }],
|
||||
});
|
||||
const response = await h.api("POST", `/policy-revisions/${policy.id}/preview`);
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
const preview = response.body.data.preview as {
|
||||
matched: number;
|
||||
excluded: number;
|
||||
samples: { sourceReleaseId: string; decision: string; reasons: string[] }[];
|
||||
};
|
||||
expect(preview).toMatchObject({ matched: 1, excluded: 1 });
|
||||
expect(preview.samples.find((sample) => sample.sourceReleaseId === h265)).toMatchObject({ decision: "accept" });
|
||||
expect(preview.samples.find((sample) => sample.sourceReleaseId === h264)).toMatchObject({ decision: "reject" });
|
||||
expect(await h.db.selectFrom("acquisition_plan").select("id").execute()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("新订阅允许先补 WEB:即使模板开启升级,也不把 REMUX 洗版默认链叠到补齐规则", async () => {
|
||||
const fill = await group("H265");
|
||||
const wash = await group("REMUX", "upgrade");
|
||||
expect(
|
||||
(await h.api("PUT", "/rules/defaults", { acquisition: [fill.id], upgrade: [wash.id], search: [] })).status,
|
||||
).toBe(200);
|
||||
await publish(
|
||||
await draft({
|
||||
defaultSlots: [
|
||||
{
|
||||
name: "先补齐再洗版",
|
||||
hardConditions: { resolutions: ["1080p"] },
|
||||
upgradeThreshold: { enabled: true, mode: "movie", basis: "priority" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const entityId = await seedEntity(h.db, { kind: "movie", title: "Fill Film", year: 2024 });
|
||||
const subscription = await h.api("POST", "/subscriptions", { mediaEntityId: entityId, mediaKind: "movie" });
|
||||
expect(subscription.status, JSON.stringify(subscription.body)).toBe(201);
|
||||
const slot = await h.db
|
||||
.selectFrom("acquisition_slot")
|
||||
.selectAll()
|
||||
.where("subscription_id", "=", subscription.body.data.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(slot.upgrade_threshold).toMatchObject({ enabled: true });
|
||||
const resource = await release(entityId, "Fill.Film.2024.1080p.WEB-DL.H265-GRP");
|
||||
const result = await evaluateAcquisition(h.d, { sourceReleaseId: resource });
|
||||
expect(result.plans, JSON.stringify(result)).toHaveLength(1);
|
||||
const plan = await h.db
|
||||
.selectFrom("acquisition_plan")
|
||||
.selectAll()
|
||||
.where("id", "=", result.plans[0]!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(plan).toMatchObject({ slot_id: slot.id, purpose: "fill", status: "reserved" });
|
||||
const spec = slotSpecWithRules(
|
||||
await loadRuleCatalog(h.db),
|
||||
slot,
|
||||
null,
|
||||
{ id: entityId, kind: "movie" },
|
||||
base.siteId,
|
||||
);
|
||||
expect(spec.rules?.groups.map((g) => g.id)).toEqual([fill.id]);
|
||||
expect(spec.upgradeRules?.groups.map((g) => g.id)).toEqual([fill.id, wash.id]);
|
||||
});
|
||||
|
||||
it("规则组被收录版本的洗版引用时不能删;通过版本更新移除引用后可删", async () => {
|
||||
const wash = await group("REMUX", "upgrade");
|
||||
const entityId = await seedEntity(h.db, { kind: "movie", title: "Slot Reference", year: 2024 });
|
||||
const created = await h.api("POST", "/acquisition-slots", {
|
||||
mediaEntityId: entityId,
|
||||
name: "洗版引用",
|
||||
upgradeThreshold: { enabled: true, ruleGroupIds: [wash.id] },
|
||||
});
|
||||
expect(created.status, JSON.stringify(created.body)).toBe(201);
|
||||
expect((await h.api("DELETE", `/rules/${wash.id}`)).status).toBe(409);
|
||||
expect(
|
||||
(await h.db.selectFrom("rule_group").select("deleted_at").where("id", "=", wash.id).executeTakeFirstOrThrow())
|
||||
.deleted_at,
|
||||
).toBeNull();
|
||||
const updated = await h.api("PATCH", `/acquisition-slots/${created.body.data.id}`, {
|
||||
expectedRevision: created.body.data.revision,
|
||||
upgradeThreshold: { enabled: true, ruleGroupIds: [] },
|
||||
});
|
||||
expect(updated.status, JSON.stringify(updated.body)).toBe(200);
|
||||
expect((await h.api("DELETE", `/rules/${wash.id}`)).status).toBe(200);
|
||||
expect(
|
||||
(await h.db.selectFrom("rule_group").select("deleted_at").where("id", "=", wash.id).executeTakeFirstOrThrow())
|
||||
.deleted_at,
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "草案 defaultSlots 补齐", published: false, newTitle: false, wash: false },
|
||||
{ label: "已发布 defaultSlots 洗版", published: true, newTitle: false, wash: true },
|
||||
{ label: "草案 newTitle 洗版", published: false, newTitle: true, wash: true },
|
||||
{ label: "已发布 newTitle 补齐", published: true, newTitle: true, wash: false },
|
||||
])("$label 引用阻止删除,解除有效策略引用后可删", async ({ published, newTitle, wash }) => {
|
||||
const referenced = await group(wash ? "REMUX" : "H265", wash ? "upgrade" : "acquisition");
|
||||
const template = {
|
||||
name: "引用模板",
|
||||
...(wash
|
||||
? { upgradeThreshold: { enabled: true, ruleGroupIds: [referenced.id] } }
|
||||
: { ruleGroupIds: [referenced.id] }),
|
||||
};
|
||||
const rules: AcquisitionPolicyRules = newTitle
|
||||
? { newTitle: { enabled: true, slots: [template] } }
|
||||
: { defaultSlots: [template] };
|
||||
const policy = await draft(rules);
|
||||
if (published) await publish(policy);
|
||||
expect((await h.api("DELETE", `/rules/${referenced.id}`)).status).toBe(409);
|
||||
if (published) {
|
||||
await publish(await draft({}, policy.policyKey));
|
||||
expect(
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("policy_revision")
|
||||
.select("status")
|
||||
.where("id", "=", policy.id)
|
||||
.executeTakeFirstOrThrow()
|
||||
).status,
|
||||
).toBe("superseded");
|
||||
} else {
|
||||
const updated = await h.api("PATCH", `/policy-revisions/${policy.id}`, { rules: {} });
|
||||
expect(updated.status, JSON.stringify(updated.body)).toBe(200);
|
||||
}
|
||||
expect((await h.api("DELETE", `/rules/${referenced.id}`)).status).toBe(200);
|
||||
});
|
||||
|
||||
it.each(["fill", "wash"] as const)("发布拒绝不存在的模板 %s 规则组,旧发布和授权保留", async (phase) => {
|
||||
const existing = await publish(await draft({}));
|
||||
const missing = randomUUID();
|
||||
const rules: AcquisitionPolicyRules =
|
||||
phase === "fill"
|
||||
? { defaultSlots: [{ name: "坏补齐引用", ruleGroupIds: [missing] }] }
|
||||
: {
|
||||
newTitle: {
|
||||
enabled: true,
|
||||
slots: [{ name: "坏洗版引用", upgradeThreshold: { enabled: true, ruleGroupIds: [missing] } }],
|
||||
},
|
||||
};
|
||||
const invalid = await draft(rules, existing.policyKey);
|
||||
const response = await h.api("POST", `/policy-revisions/${invalid.id}/publish`, {
|
||||
expectedRevision: invalid.revision,
|
||||
});
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(400);
|
||||
expect(response.body.error.message).toContain("规则组不存在");
|
||||
expect(response.body.error.message).toContain(missing);
|
||||
const policies = await h.db
|
||||
.selectFrom("policy_revision")
|
||||
.select(["id", "status"])
|
||||
.where("id", "in", [existing.id, invalid.id])
|
||||
.execute();
|
||||
expect(policies).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ id: existing.id, status: "published" },
|
||||
{ id: invalid.id, status: "draft" },
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
await h.db
|
||||
.selectFrom("automation_grant")
|
||||
.select(["status"])
|
||||
.where("policy_revision_id", "=", existing.id)
|
||||
.execute(),
|
||||
).toEqual([{ status: "active" }]);
|
||||
expect(
|
||||
await h.db.selectFrom("automation_grant").select("id").where("policy_revision_id", "=", invalid.id).execute(),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("电影忽略季级一致性,仍能从已有 WEB 资产生成 REMUX 升级计划", async () => {
|
||||
const entityId = await seedEntity(h.db, { kind: "movie", title: "Movie Upgrade", year: 2024 });
|
||||
const subscription = await h.api("POST", "/subscriptions", { mediaEntityId: entityId, mediaKind: "movie" });
|
||||
expect(subscription.status, JSON.stringify(subscription.body)).toBe(201);
|
||||
const slot = await h.db
|
||||
.selectFrom("acquisition_slot")
|
||||
.selectAll()
|
||||
.where("subscription_id", "=", subscription.body.data.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
const update = await h.api("PATCH", `/acquisition-slots/${slot.id}`, {
|
||||
expectedRevision: slot.revision,
|
||||
hardConditions: { resolutions: ["1080p"] },
|
||||
upgradeThreshold: { enabled: true, mode: "movie", basis: "tier", consistency: "season_release" },
|
||||
});
|
||||
expect(update.status, JSON.stringify(update.body)).toBe(200);
|
||||
const asset = await h.db
|
||||
.insertInto("media_asset")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
kind: "video",
|
||||
size_bytes: 300_000_000,
|
||||
media_info: JSON.stringify({
|
||||
_umt: {
|
||||
quality: {
|
||||
resolution: "1080p",
|
||||
source: "WEB-DL",
|
||||
videoCodec: "H.264",
|
||||
releaseGroup: { name: "OLD", source: "suffix" },
|
||||
hdr: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.updateTable("acquisition_slot")
|
||||
.set({ status: "satisfied", current_asset_id: asset.id })
|
||||
.where("id", "=", slot.id)
|
||||
.execute();
|
||||
const persisted = await h.db
|
||||
.selectFrom("acquisition_slot")
|
||||
.selectAll()
|
||||
.where("id", "=", slot.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(persisted.upgrade_threshold).toMatchObject({ enabled: true, consistency: "season_release" });
|
||||
const spec = slotSpecWithRules(
|
||||
await loadRuleCatalog(h.db),
|
||||
persisted,
|
||||
null,
|
||||
{ id: entityId, kind: "movie" },
|
||||
base.siteId,
|
||||
);
|
||||
expect(spec.upgrade).toMatchObject({ enabled: true, mode: "movie" });
|
||||
expect(spec.upgrade?.consistency).toBeUndefined();
|
||||
const resource = await release(entityId, "Movie.Upgrade.2024.1080p.BluRay.REMUX.H264-NEW");
|
||||
const result = await evaluateAcquisition(h.d, { sourceReleaseId: resource });
|
||||
expect(result.plans, JSON.stringify(result)).toHaveLength(1);
|
||||
const plan = await h.db
|
||||
.selectFrom("acquisition_plan")
|
||||
.selectAll()
|
||||
.where("id", "=", result.plans[0]!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(plan).toMatchObject({
|
||||
slot_id: slot.id,
|
||||
purpose: "upgrade",
|
||||
evaluation: { automaticReplacement: true, replacesAssetId: asset.id },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -79,7 +79,7 @@ describe.skipIf(!hasPg)("主密钥轮换(临时库)", () => {
|
||||
const main = fileURLToPath(new URL("../../src/main.ts", import.meta.url));
|
||||
const url = `${PG_URL}${PG_URL.includes("?") ? "&" : "?"}options=${encodeURIComponent(`-c search_path=${h.schema},public`)}`;
|
||||
const run = (extra: Record<string, string>) =>
|
||||
spawnSync("deno", ["run", "-A", main, "--role", "rotate-master-key"], {
|
||||
spawnSync(process.execPath, [main, "--role", "rotate-master-key"], {
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOME: process.env.HOME ?? "",
|
||||
|
||||
@@ -13,7 +13,7 @@ const hasPg = await pgAvailable();
|
||||
const SERVER_DIR = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
function proc(args: string[]) {
|
||||
const child = spawn(process.execPath, ["run", "-A", "test/fixtures/sse-bus-proc.ts", ...args], {
|
||||
const child = spawn(process.execPath, ["test/fixtures/sse-bus-proc.ts", ...args], {
|
||||
cwd: SERVER_DIR,
|
||||
env: { ...process.env, DATABASE_URL: PG_URL },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { coveringGrant, type PlanBinding, planBinding } from "../../src/authorization/grants.ts";
|
||||
import { createTestSchema, pgAvailable, type TestSchema, uniqueSchema } from "../support/pg.ts";
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const now = new Date("2026-01-15T12:00:00Z");
|
||||
const target: PlanBinding = {
|
||||
payloadHash: "plan-hash",
|
||||
revision: 1,
|
||||
mediaEntityId: randomUUID(),
|
||||
mediaKind: "tv",
|
||||
bytes: 100,
|
||||
};
|
||||
|
||||
describe.skipIf(!hasPg)("共用计划绑定与持续授权(真实 PG)", () => {
|
||||
let schema: TestSchema;
|
||||
beforeAll(async () => {
|
||||
schema = await createTestSchema(uniqueSchema("server_test_grants"));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await schema?.drop();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await schema.db.deleteFrom("capability_invocation").execute();
|
||||
await schema.db.deleteFrom("automation_grant").execute();
|
||||
await schema.db.deleteFrom("policy_revision").execute();
|
||||
await schema.db.deleteFrom("artifact").execute();
|
||||
});
|
||||
|
||||
const grant = (over: Record<string, unknown> = {}) =>
|
||||
schema.db
|
||||
.insertInto("automation_grant")
|
||||
.values({ action: "download.submit", granted_by: "user:test", ...over })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const check = (plan = target) => coveringGrant(schema.db, "download.submit", plan, now);
|
||||
|
||||
it("整理计划绑定读取当前内容与 hash,计划不存在时返回 null", async () => {
|
||||
const p = await schema.db
|
||||
.insertInto("artifact")
|
||||
.values({
|
||||
kind: "plan",
|
||||
title: "整理计划",
|
||||
content_hash: target.payloadHash,
|
||||
content: JSON.stringify({ mediaEntityId: target.mediaEntityId, mediaKind: target.mediaKind }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(await planBinding(schema.db, "organize_plan", p.id)).toEqual({ ...target, bytes: null });
|
||||
await schema.db.updateTable("artifact").set({ content_hash: "new-hash" }).where("id", "=", p.id).execute();
|
||||
expect((await planBinding(schema.db, "organize_plan", p.id))?.payloadHash).toBe("new-hash");
|
||||
expect(await planBinding(schema.db, "organize_plan", randomUUID())).toBeNull();
|
||||
});
|
||||
|
||||
it("授权范围同时检查能力、作品和媒体类型,series 范围覆盖 tv", async () => {
|
||||
const g = await grant({
|
||||
scope: JSON.stringify({
|
||||
capabilities: ["download.submit"],
|
||||
mediaEntityIds: [target.mediaEntityId],
|
||||
mediaKinds: ["series"],
|
||||
}),
|
||||
});
|
||||
expect(await check()).toEqual({ grantId: g.id });
|
||||
expect(await check({ ...target, mediaEntityId: randomUUID() })).toMatchObject({ reasons: [expect.any(String)] });
|
||||
expect(await check({ ...target, mediaKind: "movie" })).toMatchObject({ reasons: [expect.any(String)] });
|
||||
await schema.db
|
||||
.updateTable("automation_grant")
|
||||
.set({ scope: JSON.stringify({ capabilities: ["organize.execute"] }) })
|
||||
.where("id", "=", g.id)
|
||||
.execute();
|
||||
expect(await check()).toMatchObject({ reasons: [expect.any(String)] });
|
||||
});
|
||||
|
||||
it.each(["expired", "revoked"] as const)("%s 授权不放行", async (state) => {
|
||||
await grant(
|
||||
state === "expired" ? { expires_at: new Date(now.getTime() - 1) } : { status: "revoked", revoked_at: now },
|
||||
);
|
||||
expect(await check()).toEqual({ reasons: [] });
|
||||
});
|
||||
|
||||
it("策略被替代后,绑定该策略的持续授权不再放行", async () => {
|
||||
const p = await schema.db
|
||||
.insertInto("policy_revision")
|
||||
.values({
|
||||
policy_key: "test-acquisition",
|
||||
revision: 1,
|
||||
kind: "acquisition",
|
||||
title: "测试策略",
|
||||
status: "published",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const g = await grant({ action: "policy.acquisition", policy_revision_id: p.id });
|
||||
expect(await check()).toEqual({ grantId: g.id });
|
||||
await schema.db.updateTable("policy_revision").set({ status: "superseded" }).where("id", "=", p.id).execute();
|
||||
expect(await check()).toMatchObject({ reasons: [expect.stringContaining("已不是发布状态")] });
|
||||
});
|
||||
|
||||
it("每日次数只统计成功调用,达到预算后拒绝", async () => {
|
||||
const g = await grant({ budget: JSON.stringify({ maxInvocationsPerDay: 2 }) });
|
||||
const invoke = (status: "failed" | "succeeded") =>
|
||||
schema.db
|
||||
.insertInto("capability_invocation")
|
||||
.values({
|
||||
idempotency_key: randomUUID(),
|
||||
capability_id: "download.submit",
|
||||
capability_version: 1,
|
||||
actor_id: "user:test",
|
||||
source: "web",
|
||||
status,
|
||||
grant_id: g.id,
|
||||
created_at: now,
|
||||
})
|
||||
.execute();
|
||||
await invoke("succeeded");
|
||||
await invoke("failed");
|
||||
expect(await check()).toEqual({ grantId: g.id });
|
||||
await invoke("succeeded");
|
||||
expect(await check()).toMatchObject({ reasons: [expect.stringContaining("次数预算已用尽")] });
|
||||
});
|
||||
|
||||
it("提交下载时把本次预留字节计入预算", async () => {
|
||||
const g = await grant({ budget: JSON.stringify({ maxBytesPerDay: 100 }) });
|
||||
expect(await check()).toEqual({ grantId: g.id });
|
||||
expect(await check({ ...target, bytes: 101 })).toMatchObject({
|
||||
reasons: [expect.stringContaining("字节预算不足")],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ function harness(
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html", "www-authenticate": "Basic", "x-frame-options": "DENY" },
|
||||
});
|
||||
}) as typeof fetch,
|
||||
}) as (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
|
||||
});
|
||||
const root = new OpenAPIHono<AuthEnv>();
|
||||
root.onError((err, c) => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { createE2E, type E2E, pgAvailable, runEvents, runRow, waitFor } from "./
|
||||
// 运行(凭证只经环境变量传入,不写进代码与报告):
|
||||
// UMT_EVAL_REAL=1 UMT_EVAL_API_KEY=… UMT_EVAL_PROVIDER=opencode-go UMT_EVAL_MODEL=deepseek-v4.1-flash \
|
||||
// UMT_EVAL_LEGACY_PROMPT=/tmp/legacy-system-prompt.ts UMT_EVAL_CASES=health,acquire,why UMT_EVAL_OUT=/tmp/eval.json \
|
||||
// DATABASE_URL=… deno run -A npm:vitest run test/e2e/agent-prompt-eval.real.test.ts
|
||||
// DATABASE_URL=… bun --bun x vitest run test/e2e/agent-prompt-eval.real.test.ts
|
||||
// UMT_EVAL_LEGACY_PROMPT 用 `git show main:packages/agent/src/system-prompt.ts > /tmp/legacy-system-prompt.ts` 生成;
|
||||
// UMT_EVAL_PROMPTS=legacy,layered 控制跑哪几组(缺省两组都跑)。
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ describe.skipIf(!hasPg)("E4 崩溃恢复(worker 子进程 kill -9)", () => {
|
||||
const children: ChildProcess[] = [];
|
||||
|
||||
const spawnWorker = (): Promise<ChildProcess> => {
|
||||
const child = spawn(process.execPath, ["run", "-A", WORKER], {
|
||||
const child = spawn(process.execPath, [WORKER], {
|
||||
cwd: SERVER_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -11,17 +11,17 @@ 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")!;
|
||||
const url = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
|
||||
const biz = process.env.BIZ_SCHEMA!;
|
||||
const ow = process.env.OW_SCHEMA!;
|
||||
const sessionDir = process.env.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 logger = createLogger({ level: process.env.E2E_LOG_LEVEL === "debug" ? "debug" : "warn" });
|
||||
const config = ConfigSchema.parse({
|
||||
DATABASE_URL: url,
|
||||
SECRET_MASTER_KEY: "e2e-master-key-0123456789",
|
||||
@@ -43,10 +43,10 @@ const worker = await startWorker({
|
||||
agentConcurrency: 2,
|
||||
},
|
||||
});
|
||||
console.log(`worker ready pid=${Deno.pid}`);
|
||||
Deno.addSignalListener("SIGTERM", async () => {
|
||||
console.log(`worker ready pid=${process.pid}`);
|
||||
process.on("SIGTERM", async () => {
|
||||
await worker.stop().catch(() => {});
|
||||
await booted.engine.stop().catch(() => {});
|
||||
await db.destroy().catch(() => {});
|
||||
Deno.exit(0);
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -52,7 +52,7 @@ const mockFetch = (async (input: string | URL | Request, init?: RequestInit) =>
|
||||
if (url.href === NOTICE_URL)
|
||||
return new Response(NOTICE_HTML, { headers: { "content-type": "text/html; charset=utf-8" } });
|
||||
return new Response("not found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
}) as (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
const resolveHost = async (host: string) => (host.endsWith("example.org") ? ["93.184.216.34"] : ["203.0.114.10"]);
|
||||
|
||||
|
||||
+5
-5
@@ -1,14 +1,14 @@
|
||||
// SSE 多进程测试用子进程:sub 模式订阅并逐行输出收到的事件 JSON,收满 n 条退出;pub 模式发布 n 条后退出。
|
||||
// 用法:deno run -A test/fixtures/sse-bus-proc.ts <sub|pub> <schema> <channel> <n>
|
||||
// 用法:bun test/fixtures/sse-bus-proc.ts <sub|pub> <schema> <channel> <n>
|
||||
import type { DB } from "@umt/db";
|
||||
import { Kysely, PostgresDialect } from "kysely";
|
||||
import pg from "pg";
|
||||
import { PgEventBus } from "../../src/http/sse-pg.ts";
|
||||
import { createLogger } from "../../src/logger.ts";
|
||||
|
||||
const [mode, schema, channel, nRaw] = Deno.args;
|
||||
const [mode, schema, channel, nRaw] = process.argv.slice(2);
|
||||
const n = Number(nRaw);
|
||||
const url = Deno.env.get("DATABASE_URL") ?? "postgres://umt:umt@localhost:55433/umt";
|
||||
const url = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
|
||||
const db = new Kysely<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: url, max: 2, options: `-c search_path=${schema},public` }),
|
||||
@@ -32,12 +32,12 @@ if (mode === "sub") {
|
||||
console.log("READY");
|
||||
const timeout = setTimeout(() => {
|
||||
console.error("timeout");
|
||||
Deno.exit(3);
|
||||
process.exit(3);
|
||||
}, 20_000);
|
||||
await done;
|
||||
clearTimeout(timeout);
|
||||
} else {
|
||||
for (let i = 0; i < n; i++) await bus.publish("test.ping", { i, pid: Deno.pid });
|
||||
for (let i = 0; i < n; i++) await bus.publish("test.ping", { i, pid: process.pid });
|
||||
}
|
||||
await bus.stop();
|
||||
await db.destroy();
|
||||
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user