diff --git a/.bun-version b/.bun-version new file mode 100644 index 00000000..428b770e --- /dev/null +++ b/.bun-version @@ -0,0 +1 @@ +1.4.3 diff --git a/.dockerignore b/.dockerignore index c8ddc61e..41da1691 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 0844a141..f430f2a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 保持锁文件一致。 ## 测试 diff --git a/Dockerfile b/Dockerfile index 714dd74f..51d96d0c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/ diff --git a/README.md b/README.md index 0978530a..d04863b1 100644 --- a/README.md +++ b/README.md @@ -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。 diff --git a/apps/server/README.md b/apps/server/README.md index 0fa90cfa..67ecd098 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -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//` 目录,以及 `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//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//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)、`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//index.ts` 导出 `ServerModule`,`name` 与 manifest id 相同,写 `manifest: featureManifest("")`,加进 `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/.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("")`,加入 `modules/index.ts`。 +3. 需要事后动作时订阅生命周期事件,用 `parseEventPayload` 读取;自己发新事件时在 contracts 登记类型、schema 与 `LIFECYCLE_EVENTS`。 +4. 需要前端入口时在 `apps/web/app/features/.tsx` 注册插槽并加入 `features/index.ts`,默认直接显示。 +5. 新增 Agent 能力时同时登记 Skill 与能力地图,见根目录 `CLAUDE.md`。 +同步规则直接在所属 service 实现。目前没有通用链式钩子;`decision-hooks.ts` 只处理已有待处理事项的业务动作。功能默认启用,无需另建插件系统或开关。 + +开发、测试和容器说明见 [开发手册](../../docs/runbook/dev.md) 与 [部署手册](../../docs/runbook/deploy.md)。 diff --git a/apps/server/deno.json b/apps/server/deno.json deleted file mode 100644 index b4e8f685..00000000 --- a/apps/server/deno.json +++ /dev/null @@ -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" - } -} diff --git a/apps/server/package.json b/apps/server/package.json index a19bb6fa..a4c85f9c 100644 --- a/apps/server/package.json +++ b/apps/server/package.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:*" } } diff --git a/apps/server/src/assets.d.ts b/apps/server/src/assets.d.ts new file mode 100644 index 00000000..ec430f4e --- /dev/null +++ b/apps/server/src/assets.d.ts @@ -0,0 +1,4 @@ +declare module "*.wasm" { + const path: string; + export default path; +} diff --git a/apps/server/src/authorization/grants.ts b/apps/server/src/authorization/grants.ts new file mode 100644 index 00000000..813f155c --- /dev/null +++ b/apps/server/src/authorization/grants.ts @@ -0,0 +1,174 @@ +import { createHash } from "node:crypto"; +import { type DB, type Kysely, sql } from "@umt/db"; + +// 能力执行共用的计划绑定和持续授权检查;不依赖 Agent 会话、方案选择或审批交互。 + +/** 能力 → 可覆盖它的授权动作(策略发布生成 policy. 持续授权;"*" 全部) */ +export const GRANT_ACTIONS: Record = { + "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 = { + "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, planType: string, planId: string): Promise { + 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`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, + 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; +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 23d1016d..ba95ff41 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -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; /** 从环境变量加载并校验配置;失败时抛出可读错误(不含变量值) */ -export function loadConfig(env: Record = Deno.env.toObject()): Config { +export function loadConfig(env: Record = { ...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 = Deno.env.to export const DATA_SUBDIRS = ["agent-sessions", "backups", "exports", "tmp"] as const; /** 解析数据目录与会话目录为绝对路径 */ -export function dataPaths(c: Pick, cwd: string = Deno.cwd()) { +export function dataPaths(c: Pick, cwd: string = process.cwd()) { const dataDir = resolve(cwd, c.DATA_DIR); return { dataDir, diff --git a/apps/server/src/context.ts b/apps/server/src/context.ts index 5de771e2..57f34735 100644 --- a/apps/server/src/context.ts +++ b/apps/server/src/context.ts @@ -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 { 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( diff --git a/apps/server/src/egress.ts b/apps/server/src/egress.ts index 244a744f..06803805 100644 --- a/apps/server/src/egress.ts +++ b/apps/server/src/egress.ts @@ -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 = (() => { - const out: Record = {}; - try { - for (const k of [...PROXY_ENV, ...NO_PROXY_ENV]) out[k] = Deno.env.get(k); - } catch { - // 没有 env 权限:当作没有代理 - } - return out; -})(); - -/** 在"没有代理环境变量"的状态下同步执行 fn(建 HttpClient 用),结束立即还原 */ -function withoutProxyEnv(fn: () => T): T { - const saved = new Map(); - 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(); - -/** 明确直连的客户端(不读任何代理环境变量) */ -export function directHttpClient(): Deno.HttpClient { - direct ??= withoutProxyEnv(() => Deno.createHttpClient({})); +const direct: HttpClient = { proxy: false, close() {} }; +const proxied = new Map(); +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 { 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; } /** 站点请求的出口:开代理 → 设置里的站点代理地址,没填则用进程的 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 { return fetchVia(e.mode === "proxy" && e.proxyUrl ? proxyHttpClient(e.proxyUrl) : directHttpClient()); } @@ -128,7 +81,10 @@ export function envProxyFor(target: URL, env: Record } /** 外网请求用的 fetch:按环境变量显式选代理 / 直连(与之前隐式继承的行为一致) */ -export const externalFetch: typeof globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { +export const externalFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise = (( + 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; diff --git a/apps/server/src/hooks/index.ts b/apps/server/src/hooks/index.ts deleted file mode 100644 index 84d7733f..00000000 --- a/apps/server/src/hooks/index.ts +++ /dev/null @@ -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, point: HookPoint): boolean { - return ctx.hooks?.has(point) ?? false; -} - -/** 改写:没有钩子时原样返回同一个值;accept 判定单个钩子的结果能不能用(不能用的跳过) */ -export async function rewriteHook

( - ctx: Pick, - point: P, - value: HookValue

, - context: HookContext

| (() => HookContext

), - accept?: (v: HookValue

) => boolean, -): Promise> { - 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

( - ctx: Pick, - point: P, - context: HookContext

| (() => HookContext

), -): Promise { - const h = ctx.hooks; - if (!h?.has(point)) return null; - return h.runIntercept(point, typeof context === "function" ? context() : context); -} diff --git a/apps/server/src/hooks/points.ts b/apps/server/src/hooks/points.ts deleted file mode 100644 index c49c8846..00000000 --- a/apps/server/src/hooks/points.ts +++ /dev/null @@ -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; - }; - }; -} - -type KindOf

= (typeof HOOK_POINTS)[P]["kind"]; -export type RewritePoint = { [P in HookPoint]: KindOf

extends "rewrite" ? P : never }[HookPoint]; -export type InterceptPoint = { [P in HookPoint]: KindOf

extends "intercept" ? P : never }[HookPoint]; - -export type HookValue

= HookPointTypes[P]["value"]; -export type HookContext

= Readonly; - -type MaybePromise = T | Promise; -/** 返回新值;返回 undefined 表示不改 */ -export type RewriteHook

= ( - value: HookValue

, - context: HookContext

, -) => MaybePromise | undefined>; -/** 返回拒绝原因;返回 null / undefined 表示放行 */ -export type InterceptHook

= ( - context: HookContext

, -) => MaybePromise; diff --git a/apps/server/src/hooks/registry.ts b/apps/server/src/hooks/registry.ts deleted file mode 100644 index 5da11441..00000000 --- a/apps/server/src/hooks/registry.ts +++ /dev/null @@ -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

(point: P, fn: RewriteHook

, opts?: HookOptions): void; - intercept

(point: P, fn: InterceptHook

, 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(); - - constructor(private readonly logger?: Pick) {} - - /** 给某个模块的注册入口(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 { - return Object.fromEntries([...this.hooks].map(([p, l]) => [p, l.map((e) => e.name)])); - } - - /** 依次改写;某个钩子超时 / 抛错 / 结果不被 accept 接受时跳过它,沿用上一个值 */ - async runRewrite

( - point: P, - value: HookValue

, - context: HookContext

, - accept?: (v: HookValue

) => boolean, - ): Promise> { - 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

)) { - this.logger?.warn({ point, hook: e.name }, "钩子返回的结果不可用,已跳过"); - continue; - } - cur = r as HookValue

; - } - return cur; - } - - /** 依次判定;第一个返回原因的钩子拒绝,后面的不再执行 */ - async runIntercept

(point: P, context: HookContext

): Promise { - 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 { - let timer: ReturnType | undefined; - try { - const r = await Promise.race([ - Promise.resolve().then(() => e.fn(...args)), - new Promise((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); - } - } -} diff --git a/apps/server/src/http/static.ts b/apps/server/src/http/static.ts index 18e553e9..75f6cef4 100644 --- a/apps/server/src/http/static.ts +++ b/apps/server/src/http/static.ts @@ -1,5 +1,5 @@ import type { Hono } from "hono"; -import { serveStatic } from "hono/deno"; +import { serveStatic } from "hono/bun"; /** * 托管 React Router SPA 构建产物(build/client):存在的文件直接返回, diff --git a/apps/server/src/logger.ts b/apps/server/src/logger.ts index 8c1dbf4f..484498a5 100644 --- a/apps/server/src/logger.ts +++ b/apps/server/src/logger.ts @@ -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 }))); } diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index de1fd89f..40e5aa16 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -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 { @@ -155,9 +157,9 @@ async function runOneShot(r: OneShotRole): Promise { } 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 { } 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> = []; @@ -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")); diff --git a/apps/server/src/modules/acquisition-organize/capabilities.ts b/apps/server/src/modules/acquisition-organize/capabilities.ts index 0c8886cf..443fa3cb 100644 --- a/apps/server/src/modules/acquisition-organize/capabilities.ts +++ b/apps/server/src/modules/acquisition-organize/capabilities.ts @@ -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"; diff --git a/apps/server/src/modules/acquisition-organize/routes/rule-groups.ts b/apps/server/src/modules/acquisition-organize/routes/rule-groups.ts index 2ad419de..6223b998 100644 --- a/apps/server/src/modules/acquisition-organize/routes/rule-groups.ts +++ b/apps/server/src/modules/acquisition-organize/routes/rule-groups.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 { 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`rule_group_ids @> ${JSON.stringify([groupId])}::jsonb`) + .where( + sql`(rule_group_ids || coalesce(upgrade_threshold->'ruleGroupIds', '[]'::jsonb)) @> ${JSON.stringify([groupId])}::jsonb`, + ) .limit(50) .execute(); for (const s of slots) diff --git a/apps/server/src/modules/acquisition-organize/routes/rules.ts b/apps/server/src/modules/acquisition-organize/routes/rules.ts index a51dd5cb..4217d611 100644 --- a/apps/server/src/modules/acquisition-organize/routes/rules.ts +++ b/apps/server/src/modules/acquisition-organize/routes/rules.ts @@ -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, - 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, - }; -} diff --git a/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts b/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts index 1eb01d0a..0c80e820 100644 --- a/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts +++ b/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts @@ -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)); diff --git a/apps/server/src/modules/acquisition-organize/services/acquisition.ts b/apps/server/src/modules/acquisition-organize/services/acquisition.ts index c2c3100a..2bc74d42 100644 --- a/apps/server/src/modules/acquisition-organize/services/acquisition.ts +++ b/apps/server/src/modules/acquisition-organize/services/acquisition.ts @@ -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 { - // 收录版本名在作品内唯一(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[], ): Promise { 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 { - return db.transaction().execute(async (trx) => { +async function ensureSlotLocked(d: AoDeps, sub: SubscriptionRow): Promise { + 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, -): Promise { - 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 !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 { - 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); diff --git a/apps/server/src/modules/acquisition-organize/services/collection-strategy.ts b/apps/server/src/modules/acquisition-organize/services/collection-strategy.ts new file mode 100644 index 00000000..44cdd226 --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/services/collection-strategy.ts @@ -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 { + // 收录版本名在作品内唯一(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, + context?: { mediaKind: "movie" | "series" | "variety"; category: string | null }, +): Promise { + 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; +} diff --git a/apps/server/src/modules/acquisition-organize/services/common.ts b/apps/server/src/modules/acquisition-organize/services/common.ts index 78542b6e..fc3368db 100644 --- a/apps/server/src/modules/acquisition-organize/services/common.ts +++ b/apps/server/src/modules/acquisition-organize/services/common.ts @@ -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}` }, ); } diff --git a/apps/server/src/modules/acquisition-organize/services/coverage.ts b/apps/server/src/modules/acquisition-organize/services/coverage.ts index 5cbdcb40..7b8501f2 100644 --- a/apps/server/src/modules/acquisition-organize/services/coverage.ts +++ b/apps/server/src/modules/acquisition-organize/services/coverage.ts @@ -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, + primarySlot?: Pick, ): 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`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, ledger: typeof ledgerRows): Map => { const m = new Map(); + 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(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 => { const set = new Set(); + 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(); + 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) diff --git a/apps/server/src/modules/acquisition-organize/services/downloads.ts b/apps/server/src/modules/acquisition-organize/services/downloads.ts index 93605659..bc6d7302 100644 --- a/apps/server/src/modules/acquisition-organize/services/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/services/downloads.ts @@ -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); }); } diff --git a/apps/server/src/modules/acquisition-organize/services/evaluate-autofill.ts b/apps/server/src/modules/acquisition-organize/services/evaluate-autofill.ts index 194294ef..f444597b 100644 --- a/apps/server/src/modules/acquisition-organize/services/evaluate-autofill.ts +++ b/apps/server/src/modules/acquisition-organize/services/evaluate-autofill.ts @@ -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", diff --git a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts index fd7de519..b81b6a82 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts @@ -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, diff --git a/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts b/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts index 0d51a2dc..e4458e5e 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts @@ -435,7 +435,12 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise a.assetId === old) + ) { // 升级:旧版本保留,处置只记待处理,不删 await upsertDecision(d, trx, { kind: "other", @@ -525,17 +530,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise { + 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 成功但下载任务未移除"的记录再试。 diff --git a/apps/server/src/modules/acquisition-organize/services/organize-hooks.ts b/apps/server/src/modules/acquisition-organize/services/organize-hooks.ts deleted file mode 100644 index 7c3348d9..00000000 --- a/apps/server/src/modules/acquisition-organize/services/organize-hooks.ts +++ /dev/null @@ -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, - plan: OrganizePlan, - base: { mediaEntityId: string; mediaType: "movie" | "tv" }, -): Promise { - if (!hasHook(ctx, "organize.rename")) return 0; - const renamed = new Map(); - 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(); - 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, - 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 })), - })); -} diff --git a/apps/server/src/modules/acquisition-organize/services/organize-plan.ts b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts index 8f065a00..155c3568 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-plan.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts @@ -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 !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, + 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(); + 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, + }; +} diff --git a/apps/server/src/modules/acquisition-organize/services/replacement.ts b/apps/server/src/modules/acquisition-organize/services/replacement.ts index 4d4e75fd..462b152f 100644 --- a/apps/server/src/modules/acquisition-organize/services/replacement.ts +++ b/apps/server/src/modules/acquisition-organize/services/replacement.ts @@ -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({ diff --git a/apps/server/src/modules/acquisition-organize/services/rules.ts b/apps/server/src/modules/acquisition-organize/services/rules.ts index f005c003..82d1609f 100644 --- a/apps/server/src/modules/acquisition-organize/services/rules.ts +++ b/apps/server/src/modules/acquisition-organize/services/rules.ts @@ -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; +} diff --git a/apps/server/src/modules/acquisition-organize/services/season-release.ts b/apps/server/src/modules/acquisition-organize/services/season-release.ts new file mode 100644 index 00000000..3d5fa07e --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/services/season-release.ts @@ -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 { + 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)); +} diff --git a/apps/server/src/modules/acquisition-organize/services/subscriptions-params.ts b/apps/server/src/modules/acquisition-organize/services/subscriptions-params.ts index a24ba6d2..8c70e962 100644 --- a/apps/server/src/modules/acquisition-organize/services/subscriptions-params.ts +++ b/apps/server/src/modules/acquisition-organize/services/subscriptions-params.ts @@ -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; const curSoft = (s.soft_preferences ?? {}) as Record; - 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 diff --git a/apps/server/src/modules/acquisition-organize/services/subscriptions.ts b/apps/server/src/modules/acquisition-organize/services/subscriptions.ts index ed3041c3..32cfd5e3 100644 --- a/apps/server/src/modules/acquisition-organize/services/subscriptions.ts +++ b/apps/server/src/modules/acquisition-organize/services/subscriptions.ts @@ -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, + slot: Pick & + Partial>, opts: { entityWide?: boolean } = {}, ): Promise<{ keys: Set; episodes: Map> }> { + 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(); @@ -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(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 | 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 | 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()).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 { 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 = {}, ): Promise { - // 链式钩子 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 }) diff --git a/apps/server/src/modules/acquisition-organize/services/upgrade-replacement.ts b/apps/server/src/modules/acquisition-organize/services/upgrade-replacement.ts new file mode 100644 index 00000000..237487c0 --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/services/upgrade-replacement.ts @@ -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 { + 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(); + 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]; +} diff --git a/apps/server/src/modules/agent/authorize.ts b/apps/server/src/modules/agent/authorize.ts index 8271d792..de1f0832 100644 --- a/apps/server/src/modules/agent/authorize.ts +++ b/apps/server/src/modules/agent/authorize.ts @@ -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. 持续授权;"*" 全部) */ -export const GRANT_ACTIONS: Record = { - "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 = { - "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, planType: string, planId: string): Promise { - 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`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, - 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; 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, decisionId: string): Promise /^[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; -} diff --git a/apps/server/src/modules/agent/capabilities-download.ts b/apps/server/src/modules/agent/capabilities-download.ts index dcda184c..56ecd6ce 100644 --- a/apps/server/src/modules/agent/capabilities-download.ts +++ b/apps/server/src/modules/agent/capabilities-download.ts @@ -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 强制重新校验或恢复暂停。 diff --git a/apps/server/src/modules/agent/capabilities-metadata.ts b/apps/server/src/modules/agent/capabilities-metadata.ts index 73ea5307..12c2f316 100644 --- a/apps/server/src/modules/agent/capabilities-metadata.ts +++ b/apps/server/src/modules/agent/capabilities-metadata.ts @@ -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; resolveHost?: HostResolver; } @@ -51,7 +51,11 @@ const ROBOTS_TTL_SEC = 86400; export async function robotsAllowedFor( ctx: Pick, url: string, - opts: { fetch?: typeof fetch; resolveHost?: HostResolver; signal?: AbortSignal } = {}, + opts: { + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + resolveHost?: HostResolver; + signal?: AbortSignal; + } = {}, ): Promise { let origin: string; try { diff --git a/apps/server/src/modules/agent/interaction.ts b/apps/server/src/modules/agent/interaction.ts index a552e9b3..9f117c5d 100644 --- a/apps/server/src/modules/agent/interaction.ts +++ b/apps/server/src/modules/agent/interaction.ts @@ -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, diff --git a/apps/server/src/modules/agent/investigate.ts b/apps/server/src/modules/agent/investigate.ts index 57f938e7..3a04e9ff 100644 --- a/apps/server/src/modules/agent/investigate.ts +++ b/apps/server/src/modules/agent/investigate.ts @@ -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 ?? ""); diff --git a/apps/server/src/modules/agent/routes.ts b/apps/server/src/modules/agent/routes.ts index a9c534a1..caadfb28 100644 --- a/apps/server/src/modules/agent/routes.ts +++ b/apps/server/src/modules/agent/routes.ts @@ -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"; diff --git a/apps/server/src/modules/agent/runner.ts b/apps/server/src/modules/agent/runner.ts index 09f95718..2530bba7 100644 --- a/apps/server/src/modules/agent/runner.ts +++ b/apps/server/src/modules/agent/runner.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"), ); } diff --git a/apps/server/src/modules/agent/tasks.ts b/apps/server/src/modules/agent/tasks.ts index 4e8a89ba..12e2c449 100644 --- a/apps/server/src/modules/agent/tasks.ts +++ b/apps/server/src/modules/agent/tasks.ts @@ -266,7 +266,11 @@ export async function executeResolution(ctx: ServerContext, decisionId: string) export const agentOutboxRoutes: Record = { "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) => { diff --git a/apps/server/src/modules/browser/keepalive.ts b/apps/server/src/modules/browser/keepalive.ts index 80368f3c..05011524 100644 --- a/apps/server/src/modules/browser/keepalive.ts +++ b/apps/server/src/modules/browser/keepalive.ts @@ -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 运行时 */ } diff --git a/apps/server/src/modules/browser/service.ts b/apps/server/src/modules/browser/service.ts index 87927d4d..6f757884 100644 --- a/apps/server/src/modules/browser/service.ts +++ b/apps/server/src/modules/browser/service.ts @@ -98,7 +98,7 @@ export type Actor = { export interface BrowserModuleOptions { /** camofox 请求用的 fetch(测试指向 mock) */ - fetch?: typeof globalThis.fetch; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; /** 站点限流器所在的运行时(与快照/实时搜索共享) */ 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; downloadPollMs?: number; } diff --git a/apps/server/src/modules/cache/images.ts b/apps/server/src/modules/cache/images.ts index b9a881c8..ea8e0e05 100644 --- a/apps/server/src/modules/cache/images.ts +++ b/apps/server/src/modules/cache/images.ts @@ -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; 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; 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 { - 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 { + 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); diff --git a/apps/server/src/modules/cache/index.ts b/apps/server/src/modules/cache/index.ts index a5e9f294..d382464b 100644 --- a/apps/server/src/modules/cache/index.ts +++ b/apps/server/src/modules/cache/index.ts @@ -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; /** 图片上游基址(测试指向 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); diff --git a/apps/server/src/modules/desktop/desktop.ts b/apps/server/src/modules/desktop/desktop.ts index 7a8f37e2..c3982674 100644 --- a/apps/server/src/modules/desktop/desktop.ts +++ b/apps/server/src/modules/desktop/desktop.ts @@ -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; /** 控制台会话是否仍有效(WS 长连接期间复查) */ consoleSessionValid(sessionRowId: string): Promise; - fetch?: typeof fetch; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; /** 角色复查间隔 */ 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"; diff --git a/apps/server/src/modules/desktop/index.ts b/apps/server/src/modules/desktop/index.ts index 8f1256c1..9dd2642a 100644 --- a/apps/server/src/modules/desktop/index.ts +++ b/apps/server/src/modules/desktop/index.ts @@ -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), diff --git a/apps/server/src/modules/file-manager/common.ts b/apps/server/src/modules/file-manager/common.ts index bdf4a9ed..cfdeb899 100644 --- a/apps/server/src/modules/file-manager/common.ts +++ b/apps/server/src/modules/file-manager/common.ts @@ -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, diff --git a/apps/server/src/modules/file-manager/download.ts b/apps/server/src/modules/file-manager/download.ts index e136ab7c..8dc1a3ac 100644 --- a/apps/server/src/modules/file-manager/download.ts +++ b/apps/server/src/modules/file-manager/download.ts @@ -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(); diff --git a/apps/server/src/modules/file-manager/probe.ts b/apps/server/src/modules/file-manager/probe.ts index 432aecae..cac48316 100644 --- a/apps/server/src/modules/file-manager/probe.ts +++ b/apps/server/src/modules/file-manager/probe.ts @@ -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 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({ diff --git a/apps/server/src/modules/mcp/index.ts b/apps/server/src/modules/mcp/index.ts index 0236cbe7..ad7e856d 100644 --- a/apps/server/src/modules/mcp/index.ts +++ b/apps/server/src/modules/mcp/index.ts @@ -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() {}, diff --git a/apps/server/src/modules/mcp/sessions.ts b/apps/server/src/modules/mcp/sessions.ts index 9229e583..49d0a4a8 100644 --- a/apps/server/src/modules/mcp/sessions.ts +++ b/apps/server/src/modules/mcp/sessions.ts @@ -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); } } diff --git a/apps/server/src/modules/mcp/stats.ts b/apps/server/src/modules/mcp/stats.ts index f59646cd..af3e57a8 100644 --- a/apps/server/src/modules/mcp/stats.ts +++ b/apps/server/src/modules/mcp/stats.ts @@ -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 每次调用都写一条,含只读能力与拒绝); diff --git a/apps/server/src/modules/pi/oauth.ts b/apps/server/src/modules/pi/oauth.ts index 8c814a88..8405e6e7 100644 --- a/apps/server/src/modules/pi/oauth.ts +++ b/apps/server/src/modules/pi/oauth.ts @@ -48,7 +48,7 @@ interface Session { /** manual_code 挂起的解析函数 */ paste: ((v: string) => void) | null; pasteWaiters: (() => void)[]; - firstEvent: PromiseWithResolvers; + firstEvent: ReturnType>; done: Promise; timer: ReturnType; } diff --git a/apps/server/src/modules/sources-identity/capabilities.ts b/apps/server/src/modules/sources-identity/capabilities.ts index 7a663e1b..3a113c86 100644 --- a/apps/server/src/modules/sources-identity/capabilities.ts +++ b/apps/server/src/modules/sources-identity/capabilities.ts @@ -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; diff --git a/apps/server/src/modules/sources-identity/runtime.ts b/apps/server/src/modules/sources-identity/runtime.ts index cef5cb14..af31fde2 100644 --- a/apps/server/src/modules/sources-identity/runtime.ts +++ b/apps/server/src/modules/sources-identity/runtime.ts @@ -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; /** 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) | 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) | 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 { @@ -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 = (input, init) => { n++; return base(input, init); }; @@ -122,7 +122,7 @@ export class SourcesRuntime { private async tmdbConfig(): Promise<{ sig: string; cfg: Parameters[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 & Partial>, defaults: SitesSettings = this.siteDefaultsCache, ua?: string | (() => Promise), - ): typeof globalThis.fetch { + ): (input: RequestInfo | URL, init?: RequestInit) => Promise { 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; } /** 站点 HTTP 出口键(direct / proxy:<主机>),与浏览器会话的出口同一口径 */ @@ -223,7 +223,10 @@ export class SourcesRuntime { } /** 不加 UA 的底层 fetch(测试注入 / 按站点出口:代理或明确直连) */ - private transportFetch(site: Pick, defaults: SitesSettings): typeof globalThis.fetch { + private transportFetch( + site: Pick, + defaults: SitesSettings, + ): (input: RequestInfo | URL, init?: RequestInit) => Promise { if (this.overrides.fetch) return this.overrides.fetch; return siteFetchFor(siteEgress(site.proxy, defaults.proxyUrl)); } diff --git a/apps/server/src/modules/sources-identity/service/image-proxy.ts b/apps/server/src/modules/sources-identity/service/image-proxy.ts index 9866a466..4380c37f 100644 --- a/apps/server/src/modules/sources-identity/service/image-proxy.ts +++ b/apps/server/src/modules/sources-identity/service/image-proxy.ts @@ -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, url: string, opts: { maxBytes: number; label: string; referer?: string }, ): Promise { diff --git a/apps/server/src/modules/sources-identity/service/metadata-prewarm.ts b/apps/server/src/modules/sources-identity/service/metadata-prewarm.ts index 66292063..16631577 100644 --- a/apps/server/src/modules/sources-identity/service/metadata-prewarm.ts +++ b/apps/server/src/modules/sources-identity/service/metadata-prewarm.ts @@ -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) | 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 { diff --git a/apps/server/src/modules/sources-identity/service/release-facts-admin.ts b/apps/server/src/modules/sources-identity/service/release-facts-admin.ts new file mode 100644 index 00000000..0a24a910 --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/release-facts-admin.ts @@ -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[0], fields: readonly string[]): string[] { + const p = parsedOf(row); + const c = contextOf(row); + const values: Record = { + 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, + 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 = { + resolution: "resolution", + videoCodec: "codec", + source: "source", + releaseGroup: "group", + }; + const missing = new Set(missingReleaseFields(row, fields)); + const raw = (row.raw ?? {}) as Record; + const old = (raw._facts ?? {}) as { values?: ReleaseStaticFacts; evidence?: Record }; + 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 }; + }); +} diff --git a/apps/server/src/modules/acquisition-organize/services/release-facts.ts b/apps/server/src/modules/sources-identity/service/release-facts.ts similarity index 83% rename from apps/server/src/modules/acquisition-organize/services/release-facts.ts rename to apps/server/src/modules/sources-identity/service/release-facts.ts index 208f4b7f..81bb8f82 100644 --- a/apps/server/src/modules/acquisition-organize/services/release-facts.ts +++ b/apps/server/src/modules/sources-identity/service/release-facts.ts @@ -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; } diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index 6849d4bd..4308ab11 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -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); diff --git a/apps/server/src/modules/system/heartbeat.ts b/apps/server/src/modules/system/heartbeat.ts index 860f52d3..a48e24e7 100644 --- a/apps/server/src/modules/system/heartbeat.ts +++ b/apps/server/src/modules/system/heartbeat.ts @@ -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 { 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(); } diff --git a/apps/server/src/modules/system/index.ts b/apps/server/src/modules/system/index.ts index 50eff73f..3517cb93 100644 --- a/apps/server/src/modules/system/index.ts +++ b/apps/server/src/modules/system/index.ts @@ -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); diff --git a/apps/server/src/modules/system/resources.ts b/apps/server/src/modules/system/resources.ts index 543b27d6..618b11dc 100644 --- a/apps/server/src/modules/system/resources.ts +++ b/apps/server/src/modules/system/resources.ts @@ -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 } diff --git a/apps/server/src/modules/types.ts b/apps/server/src/modules/types.ts index df6838a6..27798195 100644 --- a/apps/server/src/modules/types.ts +++ b/apps/server/src/modules/types.ts @@ -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; @@ -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; /** 外围功能的说明(功能清单只给智能体看,不做开关);取 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 角色:启动轮询器、定时器等后台循环(引擎与中继已启动) */ diff --git a/apps/server/src/modules/websearch/service.ts b/apps/server/src/modules/websearch/service.ts index 8122a7a1..631c9e78 100644 --- a/apps/server/src/modules/websearch/service.ts +++ b/apps/server/src/modules/websearch/service.ts @@ -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; /** Brave 基址(测试指向 mock) */ braveBaseUrl?: string; /** DNS 解析(测试注入);缺省系统解析 */ diff --git a/apps/server/src/ops.ts b/apps/server/src/ops.ts index fd67cc0a..0a162413 100644 --- a/apps/server/src/ops.ts +++ b/apps/server/src/ops.ts @@ -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, appVersion: string): Promis /** 写 JSON(先写临时文件再改名,避免半截文件) */ export async function writeMappingExport(path: string, data: MappingExport): Promise { - 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); } /** 两份清单逐表比行数,返回不一致的表 */ diff --git a/apps/server/src/preflight.ts b/apps/server/src/preflight.ts index fa6378f1..2b8c2aab 100644 --- a/apps/server/src/preflight.ts +++ b/apps/server/src/preflight.ts @@ -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 { +export async function runPreflight({ + config, + db, + uid = process.getuid?.(), +}: PreflightDeps): Promise { 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 { - 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, 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(); diff --git a/apps/server/src/proc-read.ts b/apps/server/src/proc-read.ts index 66f13d75..70f1477f 100644 --- a/apps/server/src/proc-read.ts +++ b/apps/server/src/proc-read.ts @@ -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 { +import { readFile } from "node:fs/promises"; +/** Linux 资源监控和子进程 RSS;权限不足或进程已退出时返回 null。 */ +export async function readProcText(path: string): Promise { 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 { - 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; - } -} diff --git a/apps/server/src/sandbox/archives.ts b/apps/server/src/sandbox/archives.ts index dae2cb10..251ef66a 100644 --- a/apps/server/src/sandbox/archives.ts +++ b/apps/server/src/sandbox/archives.ts @@ -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; @@ -27,11 +30,18 @@ export async function prepareArchives(): Promise { 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; diff --git a/apps/server/src/sandbox/client.ts b/apps/server/src/sandbox/client.ts index 9a06ea42..6c5f7b37 100644 --- a/apps/server/src/sandbox/client.ts +++ b/apps/server/src/sandbox/client.ts @@ -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 { - 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( const timeoutMs = opts.timeoutMs ?? 20_000; const memoryMb = opts.memoryMb ?? 512; opts.signal?.throwIfAborted(); - if (inProcessReason) return runInProcess(job, input, timeoutMs) as Promise>; - 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>; + 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; + } 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; + stats.crashes += 1; + throw new SandboxError("crash", (error as Error).message); } finally { clearTimeout(timer); clearInterval(watch); opts.signal?.removeEventListener("abort", onAbort); } } - -async function runInProcess( - job: J, - input: SandboxInput, - timeoutMs: number, -): Promise { - await prepareJobs(); - const fn = SANDBOX_JOBS[job] as (i: SandboxInput) => unknown; - let timer: ReturnType | undefined; - try { - return await Promise.race([ - Promise.resolve().then(() => fn(input)), - new Promise((_, 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); - } -} diff --git a/apps/server/src/sandbox/jobs.ts b/apps/server/src/sandbox/jobs.ts index 102e8c7a..6a726f1c 100644 --- a/apps/server/src/sandbox/jobs.ts +++ b/apps/server/src/sandbox/jobs.ts @@ -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 }; } diff --git a/apps/server/src/sandbox/limits.ts b/apps/server/src/sandbox/limits.ts new file mode 100644 index 00000000..2694fc25 --- /dev/null +++ b/apps/server/src/sandbox/limits.ts @@ -0,0 +1,2 @@ +/** 单条请求/响应包括 base64 的上限;任务自己的解压条目和文件大小限制另计。 */ +export const SANDBOX_MESSAGE_BYTES = 128 * 1024 * 1024; diff --git a/apps/server/src/sandbox/native/policy.c b/apps/server/src/sandbox/native/policy.c new file mode 100644 index 00000000..90075dee --- /dev/null +++ b/apps/server/src/sandbox/native/policy.c @@ -0,0 +1,107 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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) diff --git a/apps/server/src/sandbox/policy.ts b/apps/server/src/sandbox/policy.ts new file mode 100644 index 00000000..6a9a6dd0 --- /dev/null +++ b/apps/server/src/sandbox/policy.ts @@ -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(); +} diff --git a/apps/server/src/sandbox/worker.ts b/apps/server/src/sandbox/worker.ts index b9e82f78..3a67ebdb 100644 --- a/apps/server/src/sandbox/worker.ts +++ b/apps/server/src/sandbox/worker.ts @@ -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 { + 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 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 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(); diff --git a/apps/server/src/storage/local-fs.ts b/apps/server/src/storage/local-fs.ts index 8e897fa8..d299b4d5 100644 --- a/apps/server/src/storage/local-fs.ts +++ b/apps/server/src/storage/local-fs.ts @@ -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[0]; const s = (p: unknown) => (typeof p === "string" ? p : p instanceof URL ? p.pathname : undefined); diff --git a/apps/server/src/storage/vfs.ts b/apps/server/src/storage/vfs.ts index 3df8ab45..79ce1ead 100644 --- a/apps/server/src/storage/vfs.ts +++ b/apps/server/src/storage/vfs.ts @@ -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"); } /** diff --git a/apps/server/src/worker/lanes.ts b/apps/server/src/worker/lanes.ts index 5d47c90b..47203fd6 100644 --- a/apps/server/src/worker/lanes.ts +++ b/apps/server/src/worker/lanes.ts @@ -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 运行时 } diff --git a/apps/server/test/acquisition-organize/automatic-upgrade-replacement.test.ts b/apps/server/test/acquisition-organize/automatic-upgrade-replacement.test.ts new file mode 100644 index 00000000..87009f37 --- /dev/null +++ b/apps/server/test/acquisition-organize/automatic-upgrade-replacement.test.ts @@ -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>; + let groupId: string; + const disk = fakeDisk(100_000_000_000); + const torrents = new Map(); + 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>) { + 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>, 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); +}); diff --git a/apps/server/test/acquisition-organize/collection-strategy.test.ts b/apps/server/test/acquisition-organize/collection-strategy.test.ts new file mode 100644 index 00000000..fddec9c1 --- /dev/null +++ b/apps/server/test/acquisition-organize/collection-strategy.test.ts @@ -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(); + 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>, 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")), + ); + }); +}); diff --git a/apps/server/test/acquisition-organize/download-observation-order.test.ts b/apps/server/test/acquisition-organize/download-observation-order.test.ts new file mode 100644 index 00000000..71184aa7 --- /dev/null +++ b/apps/server/test/acquisition-organize/download-observation-order.test.ts @@ -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((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>; + let tick = 0; + let hashIndex = 0; + const epoch = Date.parse("2026-10-13T00:00:00Z"); + let pending: { + method: "list" | "findByHash"; + hash?: string; + captured: ReturnType; + resume: ReturnType; + } | 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 (method: "list" | "findByHash", read: () => Promise, hash?: string): Promise => { + 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; + } + }); +}); diff --git a/apps/server/test/acquisition-organize/organize-recovery.test.ts b/apps/server/test/acquisition-organize/organize-recovery.test.ts index 606810ef..f27864b4 100644 --- a/apps/server/test/acquisition-organize/organize-recovery.test.ts +++ b/apps/server/test/acquisition-organize/organize-recovery.test.ts @@ -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") diff --git a/apps/server/test/acquisition-organize/policy-combinations.test.ts b/apps/server/test/acquisition-organize/policy-combinations.test.ts new file mode 100644 index 00000000..c677789c --- /dev/null +++ b/apps/server/test/acquisition-organize/policy-combinations.test.ts @@ -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>; + 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 }, + }); + }); +}); diff --git a/apps/server/test/auth-settings/rotate-master-key.test.ts b/apps/server/test/auth-settings/rotate-master-key.test.ts index 38f5227c..42503ec9 100644 --- a/apps/server/test/auth-settings/rotate-master-key.test.ts +++ b/apps/server/test/auth-settings/rotate-master-key.test.ts @@ -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) => - 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 ?? "", diff --git a/apps/server/test/auth-settings/sse-pg.test.ts b/apps/server/test/auth-settings/sse-pg.test.ts index 6f7615a1..ee188e40 100644 --- a/apps/server/test/auth-settings/sse-pg.test.ts +++ b/apps/server/test/auth-settings/sse-pg.test.ts @@ -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"], diff --git a/apps/server/test/authorization/grants.test.ts b/apps/server/test/authorization/grants.test.ts new file mode 100644 index 00000000..60d94940 --- /dev/null +++ b/apps/server/test/authorization/grants.test.ts @@ -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 = {}) => + 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("字节预算不足")], + }); + }); +}); diff --git a/apps/server/test/desktop/desktop.test.ts b/apps/server/test/desktop/desktop.test.ts index b25649b7..90d9db85 100644 --- a/apps/server/test/desktop/desktop.test.ts +++ b/apps/server/test/desktop/desktop.test.ts @@ -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, }); const root = new OpenAPIHono(); root.onError((err, c) => { diff --git a/apps/server/test/e2e/agent-prompt-eval.real.test.ts b/apps/server/test/e2e/agent-prompt-eval.real.test.ts index ab1289f3..25371044 100644 --- a/apps/server/test/e2e/agent-prompt-eval.real.test.ts +++ b/apps/server/test/e2e/agent-prompt-eval.real.test.ts @@ -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 控制跑哪几组(缺省两组都跑)。 diff --git a/apps/server/test/e2e/crash-recovery.test.ts b/apps/server/test/e2e/crash-recovery.test.ts index 0a55a8a7..64cddeb1 100644 --- a/apps/server/test/e2e/crash-recovery.test.ts +++ b/apps/server/test/e2e/crash-recovery.test.ts @@ -29,7 +29,7 @@ describe.skipIf(!hasPg)("E4 崩溃恢复(worker 子进程 kill -9)", () => { const children: ChildProcess[] = []; const spawnWorker = (): Promise => { - const child = spawn(process.execPath, ["run", "-A", WORKER], { + const child = spawn(process.execPath, [WORKER], { cwd: SERVER_DIR, env: { ...process.env, diff --git a/apps/server/test/e2e/fixtures/worker.ts b/apps/server/test/e2e/fixtures/worker.ts index ad3131db..9fbce67c 100644 --- a/apps/server/test/e2e/fixtures/worker.ts +++ b/apps/server/test/e2e/fixtures/worker.ts @@ -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({ 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); }); diff --git a/apps/server/test/e2e/websearch.test.ts b/apps/server/test/e2e/websearch.test.ts index fb72a98a..da8f97f7 100644 --- a/apps/server/test/e2e/websearch.test.ts +++ b/apps/server/test/e2e/websearch.test.ts @@ -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; const resolveHost = async (host: string) => (host.endsWith("example.org") ? ["93.184.216.34"] : ["203.0.114.10"]); diff --git a/apps/server/test/fixtures/sse-bus-proc.ts b/apps/server/test/fixtures/sse-bus-proc.ts index 5a7665fb..5749251e 100644 --- a/apps/server/test/fixtures/sse-bus-proc.ts +++ b/apps/server/test/fixtures/sse-bus-proc.ts @@ -1,14 +1,14 @@ // SSE 多进程测试用子进程:sub 模式订阅并逐行输出收到的事件 JSON,收满 n 条退出;pub 模式发布 n 条后退出。 -// 用法:deno run -A test/fixtures/sse-bus-proc.ts +// 用法:bun test/fixtures/sse-bus-proc.ts 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({ 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(); diff --git a/apps/server/test/hooks/hook-registry.test.ts b/apps/server/test/hooks/hook-registry.test.ts deleted file mode 100644 index 3d6d84af..00000000 --- a/apps/server/test/hooks/hook-registry.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import type { ParsedRelease } from "@umt/domain"; -import { describe, expect, it, vi } from "vitest"; -import { HookRegistry, hasHook, interceptHook, rewriteHook } from "../../src/hooks/index.ts"; - -// 链式钩子(ADR-006):注册顺序、改写、拦截、抛错隔离、超时、没有注册时零开销。 - -const warnings = () => { - const warn = vi.fn(); - return { logger: { warn } as never, warn }; -}; -const parsed = (title: string) => ({ title, raw: title, explicitIds: [] }) as unknown as ParsedRelease; -const recognizeCtx = { title: "x", subtitle: null, sourceReleaseId: null, trigger: "observed" }; -const completionCtx = { - subscriptionId: "s", - mediaEntityId: null, - mediaKind: "movie", - reason: "slot_satisfied", - progress: {}, -}; - -describe("链式钩子注册表", () => { - it("按注册顺序执行(bootstrap 按模块顺序注册),每个钩子拿到上一个的结果;缺省名 <模块>#<序号>", async () => { - const reg = new HookRegistry(); - const seen: string[] = []; - const a = reg.forModule("a"); - const b = reg.forModule("b"); - a.rewrite("organize.rename", (name) => { - seen.push(`a1:${name}`); - return `${name}-a1`; - }); - b.rewrite("organize.rename", (name) => { - seen.push(`b:${name}`); - return `${name}-b`; - }); - a.rewrite("organize.rename", (name) => { - seen.push(`a2:${name}`); - return undefined; // 不改 - }); - const ctx = { hooks: reg }; - const out = await rewriteHook(ctx, "organize.rename", "X.mkv", { - targetPath: "/lib/X.mkv", - sourcePath: "/src/X.mkv", - mediaEntityId: "e", - mediaType: "movie", - season: null, - episodes: null, - }); - expect(out).toBe("X.mkv-a1-b"); - expect(seen).toEqual(["a1:X.mkv", "b:X.mkv-a1", "a2:X.mkv-a1-b"]); - expect(reg.list()).toEqual({ "organize.rename": ["a#1", "b#1", "a#2"] }); - }); - - it("改写生效:异步钩子改写解析结果", async () => { - const reg = new HookRegistry(); - reg.forModule("m").rewrite("name.recognize", async (p) => ({ ...p, title: "改写后" })); - const out = await rewriteHook({ hooks: reg }, "name.recognize", parsed("原名"), recognizeCtx); - expect(out.title).toBe("改写后"); - }); - - it("拦截生效:第一个返回原因的钩子拒绝,后面的不再执行;都放行返回 null", async () => { - const reg = new HookRegistry(); - const later = vi.fn(() => "不该执行"); - const m = reg.forModule("m"); - m.intercept("subscription.completion.check", () => null); - m.intercept("subscription.completion.check", () => " 还缺特别篇 ", { name: "缺特别篇" }); - m.intercept("subscription.completion.check", later); - expect(await interceptHook({ hooks: reg }, "subscription.completion.check", completionCtx)).toEqual({ - reason: "还缺特别篇", - by: "缺特别篇", - }); - expect(later).not.toHaveBeenCalled(); - - const pass = new HookRegistry(); - pass.forModule("m").intercept("subscription.completion.check", () => undefined); - expect(await interceptHook({ hooks: pass }, "subscription.completion.check", completionCtx)).toBeNull(); - }); - - it("抛错隔离:出错的钩子跳过并记日志,前后的钩子照常生效", async () => { - const { logger, warn } = warnings(); - const reg = new HookRegistry(logger); - const m = reg.forModule("m"); - m.rewrite("name.recognize", (p) => ({ ...p, title: `${p.title}+1` })); - m.rewrite("name.recognize", () => { - throw new Error("插件坏了"); - }); - m.rewrite("name.recognize", async () => { - throw new Error("异步也坏了"); - }); - m.rewrite("name.recognize", (p) => ({ ...p, title: `${p.title}+2` })); - const out = await rewriteHook({ hooks: reg }, "name.recognize", parsed("t"), recognizeCtx); - expect(out.title).toBe("t+1+2"); - expect(warn).toHaveBeenCalledTimes(2); - expect(warn.mock.calls[0]![0]).toMatchObject({ - point: "name.recognize", - hook: "m#2", - err: { message: "插件坏了" }, - }); - - // 拦截类抛错按放行处理 - const r2 = new HookRegistry(logger); - r2.forModule("m").intercept("organize.intercept", () => { - throw new Error("x"); - }); - expect( - await interceptHook({ hooks: r2 }, "organize.intercept", { - mediaEntityId: "e", - mediaType: "movie", - action: "copy", - trigger: "auto", - downloadTaskId: null, - files: [], - }), - ).toBeNull(); - }); - - it("超时:超过上限的钩子跳过(沿用上一个值),不等它跑完", async () => { - const { logger, warn } = warnings(); - const reg = new HookRegistry(logger); - const m = reg.forModule("slow"); - m.rewrite("organize.rename", () => new Promise((r) => setTimeout(() => r("迟到.mkv"), 5_000)), { - timeoutMs: 30, - }); - m.rewrite("organize.rename", (n) => `${n}.ok`); - const t0 = Date.now(); - const out = await reg.runRewrite("organize.rename", "A.mkv", { - targetPath: "/l/A.mkv", - sourcePath: "/s/A.mkv", - mediaEntityId: "e", - mediaType: "movie", - season: null, - episodes: null, - }); - expect(out).toBe("A.mkv.ok"); - expect(Date.now() - t0).toBeLessThan(2_000); - expect(warn.mock.calls[0]![0]).toMatchObject({ point: "organize.rename", hook: "slow#1", timeoutMs: 30 }); - }); - - it("没有注册时零开销:不构造上下文、原样返回同一个对象;没有注册表(手搭上下文)同样", async () => { - const ctxBuilder = vi.fn(() => recognizeCtx); - const p = parsed("t"); - const empty = new HookRegistry(); - // 别的挂点有钩子不影响这里 - empty.forModule("m").intercept("organize.intercept", () => "x"); - expect(hasHook({ hooks: empty }, "name.recognize")).toBe(false); - expect(await rewriteHook({ hooks: empty }, "name.recognize", p, ctxBuilder)).toBe(p); - expect(await rewriteHook({}, "name.recognize", p, ctxBuilder)).toBe(p); - expect(await interceptHook({}, "subscription.completion.check", () => completionCtx)).toBeNull(); - expect(ctxBuilder).not.toHaveBeenCalled(); - }); - - it("调用方给了 accept 时,结果不可用的钩子跳过,后面的钩子拿到的是上一个可用值", async () => { - const { logger, warn } = warnings(); - const reg = new HookRegistry(logger); - const m = reg.forModule("m"); - m.rewrite("organize.rename", (n) => `../${n}`); - m.rewrite("organize.rename", (n) => `${n}.ok`); - const c = { - targetPath: "/a", - sourcePath: "/b", - mediaEntityId: "e", - mediaType: "movie" as const, - season: null, - episodes: null, - }; - expect(await rewriteHook({ hooks: reg }, "organize.rename", "A.mkv", c, (v) => !v.includes("/"))).toBe("A.mkv.ok"); - expect(warn.mock.calls[0]![0]).toMatchObject({ point: "organize.rename", hook: "m#1" }); - }); - - it("用错挂点类型注册直接报错", () => { - const reg = new HookRegistry(); - // biome-ignore lint/suspicious/noExplicitAny: 故意绕过类型检查 - const m = reg.forModule("m") as any; - expect(() => m.rewrite("organize.intercept", () => "x")).toThrow(/intercept 类/); - }); -}); diff --git a/apps/server/test/hooks/resource-selection.test.ts b/apps/server/test/hooks/resource-selection.test.ts deleted file mode 100644 index f1429e52..00000000 --- a/apps/server/test/hooks/resource-selection.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { SourceReleaseRow } from "@umt/db"; -import { describe, expect, it } from "vitest"; -import { HookRegistry } from "../../src/hooks/index.ts"; -import { selectResources } from "../../src/modules/acquisition-organize/services/acquisition.ts"; - -// 链式钩子 resource.selection(ADR-006,对照 MP ChainEventType.ResourceSelection):订阅重评时调整候选顺序 / 过滤, -// 只能重排与删减,不能加入新候选。 - -const rel = (id: string, title: string) => ({ id, title, parsed: null }) as unknown as SourceReleaseRow; -const rels = [ - rel("r1", "Show.S01E01.1080p.WEB-DL"), - rel("r2", "Show.S01E01.2160p.WEB-DL"), - rel("r3", "Show.S01E01.720p"), -]; -const context = { mediaEntityId: "e", subscriptionId: "s", slotId: null }; -const ctxOf = (hooks?: HookRegistry) => ({ d: { ctx: { hooks } } }) as never; - -describe("resource.selection 钩子", () => { - it("没有钩子时原样返回同一个数组", async () => { - expect(await selectResources(ctxOf(), rels, context)).toBe(rels); - expect(await selectResources(ctxOf(new HookRegistry()), rels, context)).toBe(rels); - }); - - it("重排与过滤生效;不认识的 ID 忽略、重复的只取一次", async () => { - const hooks = new HookRegistry(); - const seen: unknown[] = []; - hooks.forModule("test").rewrite("resource.selection", (cands, c) => { - seen.push({ ids: cands.map((x) => x.sourceReleaseId), c }); - const by = new Map(cands.map((x) => [x.sourceReleaseId, x])); - // 2160p 优先、去掉 720p,夹带一个不存在的候选和一个重复 - return [by.get("r2")!, { ...by.get("r1")!, sourceReleaseId: "intruder" }, by.get("r1")!, by.get("r2")!]; - }); - const out = await selectResources(ctxOf(hooks), rels, context); - expect(out.map((r) => r.id)).toEqual(["r2", "r1"]); - expect(seen).toEqual([{ ids: ["r1", "r2", "r3"], c: context }]); - }); - - it("钩子出错时沿用原顺序", async () => { - const hooks = new HookRegistry(); - hooks.forModule("test").rewrite("resource.selection", () => { - throw new Error("坏了"); - }); - expect((await selectResources(ctxOf(hooks), rels, context)).map((r) => r.id)).toEqual(["r1", "r2", "r3"]); - }); -}); diff --git a/apps/server/test/lifecycle/lifecycle-events.test.ts b/apps/server/test/lifecycle/lifecycle-events.test.ts index 4d81ca09..b568d4e2 100644 --- a/apps/server/test/lifecycle/lifecycle-events.test.ts +++ b/apps/server/test/lifecycle/lifecycle-events.test.ts @@ -2,7 +2,6 @@ import { type EventType, isEventType, LIFECYCLE_EVENTS, parseEventPayload } from import { OutboxRelay } from "@umt/tasks"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { collectOutboxRoutes } from "../../src/context.ts"; -import { HookRegistry } from "../../src/hooks/index.ts"; import { emitDownloadLifecycle, emitScrapeFinished, @@ -78,7 +77,7 @@ describe.skipIf(!hasPg)("生命周期广播事件(真实 PG)", () => { ]); }); - it("订阅完成:发已完成(按历史行去重);钩子 subscription.completion.check 拒绝时不完成、不发事件", async () => { + it("订阅完成:发已完成(按历史行去重),重复完成不重复发事件", async () => { const e = await seedEntity(h.db, { kind: "movie", title: "完成判定片", year: 2023 }); const sub = await h.db .insertInto("subscription") @@ -86,26 +85,6 @@ describe.skipIf(!hasPg)("生命周期广播事件(真实 PG)", () => { .returningAll() .executeTakeFirstOrThrow(); - const hooks = new HookRegistry(); - const seen: unknown[] = []; - hooks.forModule("test").intercept("subscription.completion.check", (c) => { - seen.push(c); - return "还要等导演剪辑版"; - }); - h.ctx.hooks = hooks; - try { - const done = await h.db.transaction().execute((trx) => completeSubscription(h.d, trx, sub, "slot_satisfied")); - expect(done).toBe(false); - } finally { - h.ctx.hooks = undefined; - } - expect(seen).toEqual([ - { subscriptionId: sub.id, mediaEntityId: e, mediaKind: "movie", reason: "slot_satisfied", progress: {} }, - ]); - const still = await h.db.selectFrom("subscription").select("status").where("id", "=", sub.id).executeTakeFirst(); - expect(still?.status).toBe("active"); - expect((await events("subscription.completed")).filter((r) => r.payload.subscriptionId === sub.id)).toEqual([]); - expect(await h.db.transaction().execute((trx) => completeSubscription(h.d, trx, sub, "slot_satisfied"))).toBe(true); // 已完成的再判一次不会重复发 expect(await h.db.transaction().execute((trx) => completeSubscription(h.d, trx, sub, "slot_satisfied"))).toBe( diff --git a/apps/server/test/lifecycle/name-recognize-hook.test.ts b/apps/server/test/lifecycle/name-recognize-hook.test.ts deleted file mode 100644 index a85c8f16..00000000 --- a/apps/server/test/lifecycle/name-recognize-hook.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { parseRelease } from "@umt/domain"; -import { createMockTmdb, type MockTmdb, TMDB_FIXTURES } from "@umt/testkit"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { HookRegistry } from "../../src/hooks/index.ts"; -import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; -import { createHarness, type Harness, pgAvailable, seedSite } from "../sources-identity/harness.ts"; - -// 链式钩子 name.recognize(ADR-006,对照 MP ChainEventType.NameRecognize):资源标题解析之后、召回之前改写解析结果。 - -const up = await pgAvailable(); -const GARBLED = "Zhuo.Wu.Ming.2023.2160p.UHD.BluRay.REMUX.HDR.HEVC-FGT"; - -describe.skipIf(!up)("name.recognize 钩子改写解析结果", () => { - let h: Harness; - let tmdb: MockTmdb; - let siteId: string; - let n = 0; - const release = async (title: string) => - h.db - .insertInto("source_release") - .values({ site_id: siteId, site_release_id: String(910000 + ++n), title }) - .returningAll() - .executeTakeFirstOrThrow(); - - beforeAll(async () => { - tmdb = await createMockTmdb({ fixtures: TMDB_FIXTURES }); - h = await createHarness("name_hook", { tmdbUrl: tmdb.url }); - siteId = (await seedSite(h, { baseUrl: "https://hook.example/" })).site.id; - await h.db - .insertInto("settings") - .values({ key: "identity", value: JSON.stringify({ eagerTmdbLookup: true }) } as never) - .execute(); - }); - afterEach(() => { - h.ctx.hooks = undefined; - }); - afterAll(async () => { - await h?.close(); - await tmdb?.close(); - }); - - it("没有钩子:认不出的标题照常不自动识别", async () => { - const r = await release(GARBLED); - const res = await resolveRelease(h.rt, { sourceReleaseId: r.id }); - expect(res.outcome).not.toBe("auto"); - }); - - it("钩子按站点别名改写解析结果后自动识别;上下文带原标题与来源资源", async () => { - const seen: unknown[] = []; - const hooks = new HookRegistry(); - hooks.forModule("test").rewrite("name.recognize", (p, c) => { - seen.push({ title: c.title, sourceReleaseId: c.sourceReleaseId, trigger: c.trigger }); - return c.title.startsWith("Zhuo.Wu.Ming") ? parseRelease(c.title.replace("Zhuo.Wu.Ming", "Oppenheimer")) : p; - }); - h.ctx.hooks = hooks; - const r = await release(GARBLED); - const res = await resolveRelease(h.rt, { sourceReleaseId: r.id }); - expect(res.outcome).toBe("auto"); - const e = await h.db - .selectFrom("external_identity") - .select("external_id") - .where("media_entity_id", "=", res.mediaEntityId!) - .where("provider", "=", "tmdb") - .executeTakeFirstOrThrow(); - expect(e.external_id).toBe("872585"); - expect(seen).toEqual([{ title: GARBLED, sourceReleaseId: r.id, trigger: "observed" }]); - }); -}); diff --git a/apps/server/test/lifecycle/organize-hooks.test.ts b/apps/server/test/lifecycle/organize-hooks.test.ts deleted file mode 100644 index b2008dac..00000000 --- a/apps/server/test/lifecycle/organize-hooks.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { existsSync } from "node:fs"; -import { createTempTree, type TempTree } from "@umt/testkit"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { HookRegistry } from "../../src/hooks/index.ts"; -import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; -import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "../acquisition-organize/harness.ts"; - -// 整理上的链式钩子(ADR-006):organize.rename 改写目标文件名(字幕跟着改)、organize.intercept 拒绝整理; -// 钩子出错不影响整理。整理结束发 organization.finished。 - -const hasPg = await pgAvailable(); -const SIZE = 200_000; -const DIR = "Inception.2010.1080p.BluRay.x264-GRP"; -const VIDEO = `${DIR}/Inception.2010.1080p.BluRay.x264-GRP.mkv`; -const SUB = `${DIR}/Inception.2010.1080p.BluRay.x264-GRP.chs.srt`; - -describe.skipIf(!hasPg)("整理钩子:改名与拦截(真实 PG + 临时目录)", () => { - let h: Harness; - let lib: TempTree; - let src: TempTree; - let entity = ""; - const req = () => ({ - sourcePaths: [src.path(DIR)], - mediaEntityId: entity, - action: "copy" as const, - trigger: "manual" as const, - actorType: "user" as const, - }); - - beforeAll(async () => { - lib = await createTempTree({}, { sentinel: true, prefix: "umt-hklib-" }); - src = await createTempTree({ [VIDEO]: SIZE, [SUB]: 2_000 }, { prefix: "umt-hksrc-" }); - h = await createHarness("organize_hooks"); - await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" }); - await h.setSettings("organize", { scrapeAfterImport: false }); - entity = await seedEntity(h.db, { kind: "movie", title: "盗梦空间", year: 2010 }); - await h.db.updateTable("media_entity").set({ original_title: "Inception" }).where("id", "=", entity).execute(); - }, 60_000); - afterEach(() => { - h.ctx.hooks = undefined; - }); - afterAll(async () => { - await h?.close(); - for (const t of [lib, src]) await t?.cleanup(); - }); - - it("拦截:钩子返回原因 → 记录失败(intercepted:<原因>),不动文件;整理结束事件 outcome=failed", async () => { - const hooks = new HookRegistry(); - const seen: string[][] = []; - hooks.forModule("test").intercept("organize.intercept", (c) => { - seen.push(c.files.map((f) => `${f.role}:${f.kind}`)); - return c.action === "copy" ? "演示:不许复制" : null; - }); - h.ctx.hooks = hooks; - const plan = await planImport(h.d, req()); - expect(plan).toMatchObject({ status: "failed", reason: "intercepted:演示:不许复制" }); - expect(seen).toEqual([["main:video", "sidecar:subtitle"]]); - const rec = await h.db - .selectFrom("organization_record") - .select(["status", "error"]) - .where("id", "=", plan.recordId!) - .executeTakeFirstOrThrow(); - expect(rec).toMatchObject({ status: "failed", error: { hook: "test#1", reason: "演示:不许复制" } }); - const fin = (await h.outbox("organization.finished")).filter((r) => r.payload.recordId === plan.recordId); - expect(fin.map((r) => [r.payload.outcome, r.payload.reason])).toEqual([["failed", "intercepted:演示:不许复制"]]); - }); - - it("改名:主视频按钩子返回的文件名入库,同主干的字幕跟着改;成功后发 organization.finished", async () => { - const hooks = new HookRegistry(); - const ctxs: unknown[] = []; - hooks.forModule("test").rewrite("organize.rename", (name, c) => { - ctxs.push({ name, mediaType: c.mediaType, mediaEntityId: c.mediaEntityId }); - return name.replace(/\.mkv$/, " [自定义].mkv"); - }); - // 返回带路径分隔符的结果会被忽略(排在后面,拿到的是上一个钩子的结果) - hooks.forModule("bad").rewrite("organize.rename", (name) => `../${name}`); - h.ctx.hooks = hooks; - const plan = await planImport(h.d, req()); - expect(plan.status).toBe("running"); - const ex = await executeRecord(h.d, plan.recordId!); - expect(ex.status).toBe("succeeded"); - expect(ctxs).toEqual([{ name: expect.stringMatching(/\.mkv$/), mediaType: "movie", mediaEntityId: entity }]); - const files = await h.db - .selectFrom("organization_file_result") - .select(["target_path", "status"]) - .where("organization_record_id", "=", plan.recordId!) - .execute(); - const targets = files.map((f) => f.target_path ?? "").sort(); - expect(targets).toHaveLength(2); - const video = targets.find((t) => t.endsWith(".mkv"))!; - const sub = targets.find((t) => t.endsWith(".srt"))!; - expect(video).toMatch(/ \[自定义\]\.mkv$/); - const stem = video.slice(0, -".mkv".length); - expect(sub.startsWith(stem)).toBe(true); - expect(existsSync(`${lib.root}/${video}`) || existsSync(video)).toBe(true); - const fin = (await h.outbox("organization.finished")).filter((r) => r.payload.recordId === plan.recordId); - expect(fin.map((r) => r.payload.outcome)).toEqual(["succeeded"]); - }); - - it("钩子出错 / 超时不影响整理:按模板名照常生成计划", async () => { - const hooks = new HookRegistry(); - hooks.forModule("broken").rewrite("organize.rename", () => { - throw new Error("坏了"); - }); - hooks.forModule("broken").intercept("organize.intercept", () => new Promise(() => {}), { timeoutMs: 20 }); - h.ctx.hooks = hooks; - const plan = await planImport(h.d, { ...req(), dryRun: true, action: "move" }); - expect(plan.status).toBe("queued"); - const main = plan.plan!.organize.files.find((f) => f.role === "main")!; - expect(main.dst).not.toContain("自定义"); - }); -}); diff --git a/apps/server/test/modules.test.ts b/apps/server/test/modules.test.ts index 66e52e0c..14a22b26 100644 --- a/apps/server/test/modules.test.ts +++ b/apps/server/test/modules.test.ts @@ -1,8 +1,8 @@ import { createRoute, z } from "@hono/zod-openapi"; -import { apiResponse, CAPABILITY_IDS, capabilitySpec, FEATURES, LIFECYCLE_EVENTS } from "@umt/contracts"; -import { defineTask, TASK_NAMES, TaskRegistry } from "@umt/tasks"; +import { apiResponse, CAPABILITY_IDS, capabilitySpec, FEATURES, LIFECYCLE_EVENTS, TaskKind } from "@umt/contracts"; +import { defineTask, TaskRegistry, UnknownTaskError } from "@umt/tasks"; import { describe, expect, it } from "vitest"; -import { buildHookRegistry, buildTaskRegistry, collectOutboxRoutes, DeferredTaskEngine } from "../src/context.ts"; +import { buildTaskRegistry, collectOutboxRoutes, DeferredTaskEngine } from "../src/context.ts"; import { createApp } from "../src/http/app.ts"; import { requireScope } from "../src/http/auth.ts"; import { EventBus } from "../src/http/sse.ts"; @@ -12,6 +12,8 @@ import { DEV_KEY, json, memoryLogger } from "./helpers.ts"; // register* 阶段模块只保存 ctx 引用,这里不需要真实依赖 const fakeCtx = { role: "all" } as unknown as ServerContext; +// agentRun 是业务记录类型,对应的执行任务叫 runAgent。 +const taskNames = TaskKind.options.map((kind) => (kind === "agentRun" ? "runAgent" : kind)); const pingRoute = createRoute({ method: "get", @@ -85,22 +87,25 @@ describe("ServerModule 骨架", () => { expect((await app.request("/api/v1/nope", { headers: { authorization: `Bearer ${DEV_KEY}` } })).status).toBe(404); }); - it("任务注册表:模块任务优先,其余 §3.1-8 任务由空壳兜底;重复注册报错", async () => { + it("任务注册表只收录模块实现;未注册任务和重复注册明确报错", async () => { const reg = buildTaskRegistry([...SERVER_MODULES, pingModule], fakeCtx); - expect(reg.names()).toEqual(expect.arrayContaining([...TASK_NAMES, "testPing"])); + expect(reg.names()).toEqual(expect.arrayContaining([...taskNames, "testPing"])); const ingest = reg.latest("testPing"); expect(await ingest.handler({ input: {} } as never)).toBe("real"); + const partial = buildTaskRegistry([pingModule], fakeCtx); + expect(partial.names()).toEqual(["testPing"]); + expect(() => partial.latest("submitDownload")).toThrow(UnknownTaskError); expect(() => buildTaskRegistry([pingModule, { ...pingModule, name: "dup" }], fakeCtx)).toThrow(/重复注册/); }); - it("生产装配:每个核心任务都由业务模块正式注册,不落到空壳兜底", () => { + it("生产装配:契约中的每个业务任务都有正式处理器", () => { const owned = new Set(); for (const m of SERVER_MODULES) { const own = new TaskRegistry(); m.registerTasks(own, fakeCtx); for (const name of own.names()) owned.add(name); } - expect(TASK_NAMES.filter((n) => !owned.has(n))).toEqual([]); + expect(taskNames.filter((n) => !owned.has(n))).toEqual([]); }); it("outbox 路由合并;同一非广播事件被两个模块认领报错", () => { @@ -171,34 +176,6 @@ describe("ServerModule 骨架", () => { expect(Object.keys(subtitles.outboxRoutes?.(fakeCtx) ?? {})).toEqual(subtitles.manifest?.subscribes); }); - it("链式钩子按模块顺序注册;生产装配目前没有模块注册钩子(无注册零开销)", async () => { - const order: string[] = []; - const mod = (name: string): ServerModule => ({ - name, - registerRoutes() {}, - registerTasks() {}, - registerHooks(hooks) { - hooks.rewrite("organize.rename", (n) => { - order.push(name); - return `${n}.${name}`; - }); - }, - }); - const hooks = buildHookRegistry([mod("first"), mod("second")], fakeCtx); - expect( - await hooks.runRewrite("organize.rename", "a", { - targetPath: "/a", - sourcePath: "/b", - mediaEntityId: "e", - mediaType: "movie", - season: null, - episodes: null, - }), - ).toBe("a.first.second"); - expect(order).toEqual(["first", "second"]); - expect(buildHookRegistry(SERVER_MODULES, fakeCtx).list()).toEqual({}); - }); - it("引擎就绪前调用给出明确错误", async () => { const e = new DeferredTaskEngine(); expect(e.ready).toBe(false); diff --git a/apps/server/test/organize-in-place-verify.test.ts b/apps/server/test/organize-in-place-verify.test.ts index acd64836..a4b716a3 100644 --- a/apps/server/test/organize-in-place-verify.test.ts +++ b/apps/server/test/organize-in-place-verify.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; // 不挂载 HTTP 路由;隔离 Deno 静态文件适配器,以便本单元测试在 Node 下执行。 -vi.mock("hono/deno", () => ({ serveStatic: vi.fn() })); +vi.mock("hono/bun", () => ({ serveStatic: vi.fn() })); import { verifyInPlace } from "../src/modules/acquisition-organize/services/organize.ts"; diff --git a/apps/server/test/preflight.test.ts b/apps/server/test/preflight.test.ts index 13e3cfd5..6ae94475 100644 --- a/apps/server/test/preflight.test.ts +++ b/apps/server/test/preflight.test.ts @@ -62,7 +62,7 @@ describe("存储自检", () => { mkdirSync(ok); mkdirSync(ro); chmodSync(ro, 0o555); - const isRoot = Deno.uid() === 0; + const isRoot = process.getuid?.() === 0; const { problems, warnings } = await checkStorage({ dataDir, sessionDir: join(dataDir, "agent-sessions"), @@ -82,7 +82,7 @@ describe("存储自检", () => { }); it("数据目录不可写 → problem", async () => { - if (Deno.uid() === 0) return; // root 无视权限位 + if (process.getuid?.() === 0) return; // root 无视权限位 const locked = join(dir, "locked"); mkdirSync(locked); chmodSync(locked, 0o555); diff --git a/apps/server/test/sandbox/sandbox.test.ts b/apps/server/test/sandbox/sandbox.test.ts index 370845d5..e65c3548 100644 --- a/apps/server/test/sandbox/sandbox.test.ts +++ b/apps/server/test/sandbox/sandbox.test.ts @@ -16,13 +16,34 @@ describe("沙箱子进程", () => { it("正常返回;子进程里读文件被拒绝", async () => { expect(await runSandbox("self.test", { mode: "ok" })).toEqual({ ok: true }); - await expect(runSandbox("self.test", { mode: "io" })).rejects.toMatchObject({ kind: "job", message: /NotCapable/ }); + await expect(runSandbox("self.test", { mode: "io" })).rejects.toMatchObject({ + kind: "job", + message: /EPERM|operation not permitted/i, + }); + }); + + it("不继承父进程密钥,也不自动读取 dotenv", async () => { + process.env.UMT_SANDBOX_TEST_SECRET = "private"; + try { + const result = await runSandbox("self.test", { mode: "env" }); + expect(result).toEqual({ keys: ["PATH"] }); + } finally { + delete process.env.UMT_SANDBOX_TEST_SECRET; + } + }); + + it.each(["async-io", "write", "net", "exec"] as const)("内核拒绝 %s,包括运行时工作线程", async (mode) => { + await expect(runSandbox("self.test", { mode })).rejects.toMatchObject({ + kind: "job", + message: /EPERM|operation not permitted/i, + }); }); it("卡死:超时后强杀,不拖住调用方", async () => { const t0 = Date.now(); const e = await runSandbox("self.test", { mode: "hang" }, { timeoutMs: 800 }).catch((x) => x); expect(e).toBeInstanceOf(SandboxError); + if (!(e instanceof SandboxError)) throw e; expect(e.kind).toBe("timeout"); expect(Date.now() - t0).toBeLessThan(5000); }); @@ -30,6 +51,7 @@ describe("沙箱子进程", () => { it("吃内存:超过上限强杀", async () => { const e = await runSandbox("self.test", { mode: "memory" }, { memoryMb: 128, timeoutMs: 20_000 }).catch((x) => x); expect(e).toBeInstanceOf(SandboxError); + if (!(e instanceof SandboxError)) throw e; expect(e.kind).toBe("memory"); }); diff --git a/apps/server/test/scrape/gen-scrape-layout-mp.py b/apps/server/test/scrape/gen-scrape-layout-mp.py index 39b8bc6c..d99a1101 100644 --- a/apps/server/test/scrape/gen-scrape-layout-mp.py +++ b/apps/server/test/scrape/gen-scrape-layout-mp.py @@ -141,4 +141,4 @@ for name, (target, kind) in SCENARIOS.items(): (HERE / "scrape-layout-mp.json").write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(json.dumps(out, ensure_ascii=False, indent=2)) -# 生成后在本仓库跑一次格式化(lint 要求):deno run -A npm:@biomejs/biome check --write apps/server/test/scrape/scrape-layout-mp.json +# 生成后在本仓库跑一次格式化(lint 要求):bun npm:@biomejs/biome check --write apps/server/test/scrape/scrape-layout-mp.json diff --git a/apps/server/test/search/discover.test.ts b/apps/server/test/search/discover.test.ts index cdbaad60..68fe5754 100644 --- a/apps/server/test/search/discover.test.ts +++ b/apps/server/test/search/discover.test.ts @@ -78,7 +78,7 @@ describe.skipIf(!up)("发现页接口", () => { settings: plainSettings(() => db), capabilities: createCapabilityRegistry({ logger }), } as ServerContext; - const metaFetch: typeof fetch = async (input, init) => { + const metaFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise = async (input, init) => { const u = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); meta.push(`${init?.method ?? "GET"} ${u.host}${u.pathname}${u.search}`); if (u.pathname.startsWith("/rexxar/api/v2/subject_collection/")) return doubanReply(); diff --git a/apps/server/test/search/search.test.ts b/apps/server/test/search/search.test.ts index d8371e38..4c28bf4e 100644 --- a/apps/server/test/search/search.test.ts +++ b/apps/server/test/search/search.test.ts @@ -153,7 +153,7 @@ describe.skipIf(!up)("全局搜索", () => { capabilities: createCapabilityRegistry({ logger }), } as ServerContext; // 挂起的站点:请求一直不返回,直到被中止(单站超时) - const fetchImpl: typeof fetch = (input, init) => { + const fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.hostname !== SLOW_HOST) return fetch(input, init); slowCalls++; diff --git a/apps/server/test/sources-identity/backfill-languages.test.ts b/apps/server/test/sources-identity/backfill-languages.test.ts index 764190dd..0d3e893a 100644 --- a/apps/server/test/sources-identity/backfill-languages.test.ts +++ b/apps/server/test/sources-identity/backfill-languages.test.ts @@ -99,8 +99,8 @@ describe.skipIf(!up)("元数据语言回刷", () => { .values({ key: "metadata", value: JSON.stringify({ tmdbBaseUrl: `${tmdb.url}/3` }) } as never) .onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify({ tmdbBaseUrl: `${tmdb.url}/3` }) })) .execute(); - const prev = Deno.env.get("TMDB_API_KEY"); - Deno.env.set("TMDB_API_KEY", "test-tmdb-key"); + const prev = process.env.TMDB_API_KEY; + process.env.TMDB_API_KEY = "test-tmdb-key"; try { const rt = new SourcesRuntime(h.ctx, {}); const t0 = performance.now(); @@ -112,8 +112,8 @@ describe.skipIf(!up)("元数据语言回刷", () => { const second = await backfillLanguages(rt, { minIntervalMs: 100 }); expect(second).toMatchObject({ refreshed: 2, requests: 0 }); } finally { - if (prev === undefined) Deno.env.delete("TMDB_API_KEY"); - else Deno.env.set("TMDB_API_KEY", prev); + if (prev === undefined) delete process.env.TMDB_API_KEY; + else process.env.TMDB_API_KEY = prev; } }); diff --git a/apps/server/test/sources-identity/cloudflare.test.ts b/apps/server/test/sources-identity/cloudflare.test.ts index a2efc7a8..d205b17f 100644 --- a/apps/server/test/sources-identity/cloudflare.test.ts +++ b/apps/server/test/sources-identity/cloudflare.test.ts @@ -118,7 +118,7 @@ describe.skipIf(!up)("CF-2 Cloudflare 级联", () => { } cf.passed++; return globalThis.fetch(input, init); - }) as typeof fetch; + }) as (input: RequestInfo | URL, init?: RequestInit) => Promise; const site = () => h.db.selectFrom("site").selectAll().where("id", "=", siteId).executeTakeFirstOrThrow(); const account = () => diff --git a/apps/server/test/sources-identity/data-hygiene.test.ts b/apps/server/test/sources-identity/data-hygiene.test.ts index c607067d..037f7ea2 100644 --- a/apps/server/test/sources-identity/data-hygiene.test.ts +++ b/apps/server/test/sources-identity/data-hygiene.test.ts @@ -285,7 +285,7 @@ describe.skipIf(!up)("DH-1 内容单元数据治理", () => { const u = new URL(PG_URL); u.searchParams.set("options", `-c search_path=${schema},public`); const run = (...args: string[]) => - execFileSync(Deno.execPath(), ["run", "-A", SCRIPT, "--merge-duplicates", ...args], { + execFileSync(process.execPath, [SCRIPT, "--merge-duplicates", ...args], { cwd: ROOT, env: { ...process.env, DATABASE_URL: u.toString(), NO_COLOR: "1" }, encoding: "utf8", diff --git a/apps/server/test/sources-identity/egress.test.ts b/apps/server/test/sources-identity/egress.test.ts index 411623a7..cf15a053 100644 --- a/apps/server/test/sources-identity/egress.test.ts +++ b/apps/server/test/sources-identity/egress.test.ts @@ -1,4 +1,5 @@ import { DEFAULT_SETTINGS } from "@umt/contracts"; +import { type HttpServer, serve } from "@umt/runtime"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { directHttpClient, @@ -27,66 +28,58 @@ const PROXY_KEYS = [ ]; describe("出口选择(直连 / 代理)", () => { - let proxy: Deno.HttpServer; - let target: Deno.HttpServer; + let proxy: HttpServer; + let target: HttpServer; let proxyUrl = ""; let targetUrl = ""; const proxied: string[] = []; const saved = new Map(); beforeAll(() => { - proxy = Deno.serve({ hostname: "127.0.0.1", port: 0, onListen() {} }, (req) => { + proxy = serve({ hostname: "127.0.0.1", port: 0, onListen() {} }, (req) => { proxied.push(req.url); return new Response("via-proxy"); }); - target = Deno.serve({ hostname: "127.0.0.1", port: 0, onListen() {} }, () => new Response("direct")); + target = serve({ hostname: "127.0.0.1", port: 0, onListen() {} }, () => new Response("direct")); proxyUrl = `http://127.0.0.1:${proxy.addr.port}`; targetUrl = `http://127.0.0.1:${target.addr.port}/x`; for (const k of PROXY_KEYS) { - saved.set(k, Deno.env.get(k)); - Deno.env.delete(k); + saved.set(k, process.env[k]); + delete process.env[k]; } // 模拟容器环境:HTTP_PROXY 指向"中继",没有 NO_PROXY - Deno.env.set("HTTP_PROXY", proxyUrl); + process.env.HTTP_PROXY = proxyUrl; }); afterAll(async () => { for (const [k, v] of saved) { - if (v === undefined) Deno.env.delete(k); - else Deno.env.set(k, v); + if (v === undefined) delete process.env[k]; + else process.env[k] = v; } await proxy.shutdown(); await target.shutdown(); }); - const body = async (f: typeof fetch) => (await f(targetUrl)).text(); - - it("Deno 2.9 语义:createHttpClient({}) 仍会读 HTTP_PROXY(所以直连要显式做)", async () => { - const implicit = Deno.createHttpClient({}); - try { - expect(await body(fetchVia(implicit))).toBe("via-proxy"); - } finally { - implicit.close(); - } - }); + const body = async (f: (input: RequestInfo | URL, init?: RequestInit) => Promise) => + (await f(targetUrl)).text(); it("directHttpClient:环境变量里有代理也直连;创建后环境变量原样还原", async () => { proxied.length = 0; expect(await body(fetchVia(directHttpClient()))).toBe("direct"); expect(proxied).toEqual([]); - expect(Deno.env.get("HTTP_PROXY")).toBe(proxyUrl); + expect(process.env.HTTP_PROXY).toBe(proxyUrl); }); it("proxyHttpClient:只走给定代理(NO_PROXY 命中也不绕过)", async () => { - Deno.env.set("NO_PROXY", "127.0.0.1"); + process.env.NO_PROXY = "127.0.0.1"; try { proxied.length = 0; const client = proxyHttpClient(proxyUrl); expect(await body(fetchVia(client))).toBe("via-proxy"); expect(proxied).toHaveLength(1); - expect(Deno.env.get("NO_PROXY")).toBe("127.0.0.1"); + expect(process.env.NO_PROXY).toBe("127.0.0.1"); } finally { - Deno.env.delete("NO_PROXY"); + delete process.env.NO_PROXY; } }); diff --git a/apps/server/test/sources-identity/enrich.test.ts b/apps/server/test/sources-identity/enrich.test.ts index fa7a4d31..8ad8d248 100644 --- a/apps/server/test/sources-identity/enrich.test.ts +++ b/apps/server/test/sources-identity/enrich.test.ts @@ -64,7 +64,7 @@ const mockFetch = (async (input: string | URL | Request) => { if (url.href === EVIL) return new Response("xx", { headers: { "content-type": "text/html" } }); return new Response("not found", { status: 404 }); -}) as typeof fetch; +}) as (input: RequestInfo | URL, init?: RequestInit) => Promise; const resolveHost = async () => ["93.184.216.34"]; /** fake 模型:照网页给值,简介是改写过的(不是原文),海报给一个网页里没有的地址(应被丢弃) */ @@ -360,7 +360,7 @@ describe.skipIf(!pg)("META-1 元数据补全", () => { return new Response("User-agent: *\nDisallow: /series/\n", { headers: { "content-type": "text/plain" } }); if (u.hostname === "robots-b.example.com") return new Response("err", { status: 503 }); return new Response("nf", { status: 404 }); - }) as typeof fetch; + }) as (input: RequestInfo | URL, init?: RequestInit) => Promise; const o = { fetch: f, resolveHost }; expect(await robotsAllowedFor(h.ctx, "https://robots-a.example.com/series/1", o)).toBe(false); expect(await robotsAllowedFor(h.ctx, "https://robots-a.example.com/detail?id=1", o)).toBe(true); diff --git a/apps/server/test/sources-identity/failure-sample.test.ts b/apps/server/test/sources-identity/failure-sample.test.ts index d0aae310..bfd613c9 100644 --- a/apps/server/test/sources-identity/failure-sample.test.ts +++ b/apps/server/test/sources-identity/failure-sample.test.ts @@ -42,7 +42,7 @@ describe.skipIf(!up)("站点失败留样", () => { new Response(page, { status: 200, headers: { "content-type": "text/html; charset=utf-8", server: "nginx" }, - })) as typeof fetch; + })) as (input: RequestInfo | URL, init?: RequestInit) => Promise; const site = () => h.db.selectFrom("site").selectAll().where("id", "=", siteId).executeTakeFirstOrThrow(); const rows = () => h.db.selectFrom("site_failure_sample").selectAll().where("site_id", "=", siteId).orderBy("created_at").execute(); diff --git a/apps/server/test/sources-identity/identity-cleanup.test.ts b/apps/server/test/sources-identity/identity-cleanup.test.ts index 69daeaa6..03803ad2 100644 --- a/apps/server/test/sources-identity/identity-cleanup.test.ts +++ b/apps/server/test/sources-identity/identity-cleanup.test.ts @@ -18,7 +18,7 @@ describe.skipIf(!up)("identity-cleanup 孤立单元判定", () => { let mappedNoLayout: string; const run = (...args: string[]) => - execFileSync(Deno.execPath(), ["run", "-A", SCRIPT, ...args], { + execFileSync(process.execPath, [SCRIPT, ...args], { cwd: ROOT, env: { ...process.env, DATABASE_URL: url, NO_COLOR: "1" }, encoding: "utf8", diff --git a/apps/server/test/sources-identity/identity-differential.lib.ts b/apps/server/test/sources-identity/identity-differential.lib.ts index 73f700bb..e7cd1ecd 100644 --- a/apps/server/test/sources-identity/identity-differential.lib.ts +++ b/apps/server/test/sources-identity/identity-differential.lib.ts @@ -123,7 +123,9 @@ export function trimTmdb(body: unknown): unknown { } /** 离线回放:按键取录制的响应;没录到的请求直接报错(说明召回逻辑变了,需要重新录制) */ -export function fixtureFetch(fixtures: Record): typeof fetch { +export function fixtureFetch( + fixtures: Record, +): (input: RequestInfo | URL, init?: RequestInit) => Promise { return (async (input: string | URL | Request) => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const key = fixtureKey(url); @@ -134,10 +136,13 @@ export function fixtureFetch(fixtures: Record): typeof fetch { status, headers: { "content-type": "application/json" }, }); - }) as typeof fetch; + }) as (input: RequestInfo | URL, init?: RequestInit) => Promise; } -export function tmdbFor(fetchImpl: typeof fetch, baseUrl = "http://tmdb.fixture/3"): TmdbClient { +export function tmdbFor( + fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise, + baseUrl = "http://tmdb.fixture/3", +): TmdbClient { return new TmdbClient({ apiKey: "x", baseUrl, fetch: fetchImpl, retries: 0 }); } diff --git a/apps/server/test/sources-identity/identity-differential.run.ts b/apps/server/test/sources-identity/identity-differential.run.ts index 72d48eb4..e84b27f8 100644 --- a/apps/server/test/sources-identity/identity-differential.run.ts +++ b/apps/server/test/sources-identity/identity-differential.run.ts @@ -2,7 +2,7 @@ * 重新录制身份匹配差分(ID-2):我们侧的 TMDB 请求走本地缓存代理(scratchpad tmdb_proxy.py,上行计数封顶), * 录下的响应裁剪后写入 tmdb-fixtures.json,逐条结果写入 ours.json 并打印汇总。 * - * TMDB_PROXY=http://127.0.0.1:18731 deno run -A apps/server/test/sources-identity/identity-differential.run.ts + * TMDB_PROXY=http://127.0.0.1:18731 bun apps/server/test/sources-identity/identity-differential.run.ts */ import { writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -16,9 +16,9 @@ import { trimTmdb, } from "./identity-differential.lib.ts"; -const proxy = Deno.env.get("TMDB_PROXY") ?? "http://127.0.0.1:18731"; +const proxy = process.env.TMDB_PROXY ?? "http://127.0.0.1:18731"; const fixtures: Record = {}; -const recording: typeof fetch = async (input, init) => { +const recording: (input: RequestInfo | URL, init?: RequestInit) => Promise = async (input, init) => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const res = await fetch(url, init); const body = await res.clone().json(); diff --git a/apps/server/test/sources-identity/release-facts-recovery.test.ts b/apps/server/test/sources-identity/release-facts-recovery.test.ts new file mode 100644 index 00000000..582bfd85 --- /dev/null +++ b/apps/server/test/sources-identity/release-facts-recovery.test.ts @@ -0,0 +1,438 @@ +import { randomUUID } from "node:crypto"; +import { + CAPABILITY_CATALOG, + type DecisionStatus, + type KnownCapabilityId, + ReleaseApplyFactsInput, + type ReleaseStaticFacts, +} from "@umt/contracts"; +import { evaluateCandidate, parseRelease, type SlotSpec } from "@umt/domain"; +import { toParsedColumns } from "@umt/mapping"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts"; +import { autoInvestigate, checkRecovery, recoveryActions } from "../../src/modules/agent/investigate.ts"; +import { registerSourcesCapabilities } from "../../src/modules/sources-identity/capabilities.ts"; +import { parsedOf } from "../../src/modules/sources-identity/service/release-facts.ts"; +import { applyReleaseFacts } from "../../src/modules/sources-identity/service/release-facts-admin.ts"; +import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts"; + +// 真实 PG、假引擎、不调用模型或站点:事实补全必须受事项和授权约束,补完仍按原规则判断。 +const pg = await pgAvailable(); +const EVIDENCE = "资源详情 MediaInfo 的视频轨道标明宽 1920、高 1080,编码 AVC。"; + +describe.skipIf(!pg)("资源事实补全与恢复", () => { + let h: Harness; + let siteId: string; + let entityId: string; + let anotherEntityId: string; + let n = 0; + + beforeAll(async () => { + h = await createHarness("release_facts_recovery"); + siteId = (await seedSite(h, { baseUrl: "https://facts.example.test/" })).site.id; + const entities = await h.db + .insertInto("media_entity") + .values([ + { kind: "movie", title: "Silent Meridian", year: 2025, status: "active" }, + { kind: "movie", title: "Another Film", year: 2024, status: "active" }, + ]) + .returning("id") + .execute(); + entityId = entities[0]!.id; + anotherEntityId = entities[1]!.id; + for (const [key, value] of Object.entries({ + automation: { enabled: true }, + agent: { autoInvestigate: true, autoInvestigateMode: "recover", investigateMaxAttempts: 3 }, + })) { + await h.db + .insertInto("settings") + .values({ key, value: JSON.stringify(value) }) + .onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify(value) })) + .execute(); + } + const registry = createCapabilityRegistry({ logger: h.ctx.logger, db: h.db, settings: h.ctx.settings }); + registerSourcesCapabilities(registry, () => h.rt); + h.ctx.capabilities = registry; + }); + + beforeEach(async () => { + await h.db.deleteFrom("automation_grant").execute(); + }); + + afterAll(async () => { + await h?.close(); + }); + + async function seed(opts: { title?: string; missing?: string[]; status?: DecisionStatus } = {}) { + const title = opts.title ?? "Silent Meridian 2025"; + const columns = toParsedColumns(parseRelease(title)); + const release = await h.db + .insertInto("source_release") + .values({ + site_id: siteId, + site_release_id: String(++n), + title, + media_entity_id: entityId, + resolution_status: "resolved", + parsed: JSON.stringify(columns.parsed), + parser_version: columns.parserVersion, + raw: JSON.stringify({ _site: { marker: "snapshot" }, _detail: { imdbId: "tt1234567" } }), + }) + .returningAll() + .executeTakeFirstOrThrow(); + const decision = await h.db + .insertInto("decision") + .values({ + kind: "other", + title: "资源属性缺失", + status: opts.status ?? "open", + subject_type: "source_release", + subject_id: release.id, + dedupe_key: `release_facts:${release.id}`, + evidence: JSON.stringify({ + sourceReleaseId: release.id, + mediaEntityId: entityId, + missing: opts.missing ?? ["resolution"], + }), + }) + .returningAll() + .executeTakeFirstOrThrow(); + return { releaseId: release.id, decisionId: decision.id }; + } + + function input(target: Awaited>, facts: ReleaseStaticFacts = { resolution: "1080p" }) { + return ReleaseApplyFactsInput.parse({ + decisionId: target.decisionId, + sourceReleaseId: target.releaseId, + facts, + evidence: EVIDENCE, + evidenceUrl: "https://facts.example.test/details.php?id=1", + }); + } + + const releaseOf = (id: string) => + h.db.selectFrom("source_release").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); + const decisionOf = (id: string) => + h.db.selectFrom("decision").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); + const eventsOf = async (id: string) => + (await h.outbox("release.resolved")).filter((event) => event.payload.sourceReleaseId === id); + const actionOf = (target: Awaited>) => [ + { + capabilityId: "release.apply_facts", + input: input(target), + status: "succeeded", + }, + ]; + + async function grant(allowedEntityId = entityId, action = "release.apply_facts") { + return h.db + .insertInto("automation_grant") + .values({ + action, + scope: JSON.stringify({ mediaEntityIds: [allowedEntityId] }), + granted_by: "admin:test", + reason: "只补全资源事实", + }) + .returning("id") + .executeTakeFirstOrThrow(); + } + + function invoke(target: Awaited>, runId: string) { + return h.ctx.capabilities.invoke("release.apply_facts", input(target), { + actorId: "system:auto-recover", + taskId: randomUUID(), + scopeIds: [], + signal: new AbortController().signal, + source: "agent", + agentRunId: runId, + invocationId: randomUUID(), + }); + } + + it("事项与资源必须一致,拒绝后没有写入或重评事件", async () => { + const first = await seed(); + const second = await seed(); + const before = await releaseOf(second.releaseId); + await expect(applyReleaseFacts(h.ctx, { ...input(first), sourceReleaseId: second.releaseId })).rejects.toThrow( + /事项.*资源.*不一致/, + ); + expect((await releaseOf(second.releaseId)).raw).toEqual(before.raw); + expect((await decisionOf(first.decisionId)).status).toBe("open"); + expect(await eventsOf(second.releaseId)).toHaveLength(0); + }); + + it("只填事项里仍缺失的字段,已知字段及未请求字段均不能覆盖", async () => { + const target = await seed({ title: "Silent Meridian 2025 2160p", missing: ["resolution", "codec"] }); + const before = await releaseOf(target.releaseId); + await expect(applyReleaseFacts(h.ctx, input(target, { resolution: "1080p", videoCodec: "H.264" }))).rejects.toThrow( + /只能补全/, + ); + await expect(applyReleaseFacts(h.ctx, input(target, { source: "WEB-DL" }))).rejects.toThrow(/只能补全/); + expect((await releaseOf(target.releaseId)).raw).toEqual(before.raw); + expect(parsedOf(await releaseOf(target.releaseId))).toMatchObject({ resolution: "2160p" }); + expect(await eventsOf(target.releaseId)).toHaveLength(0); + expect(await applyReleaseFacts(h.ctx, input(target, { videoCodec: "H.264" }))).toEqual({ + resolved: true, + remaining: [], + }); + expect(parsedOf(await releaseOf(target.releaseId))).toMatchObject({ resolution: "2160p", videoCodec: "H.264" }); + }); + + it.each(["dismissed", "expired"] as const)("%s 的事项不能再写事实或重新打开", async (status) => { + const target = await seed({ status }); + const before = await releaseOf(target.releaseId); + await expect(applyReleaseFacts(h.ctx, input(target))).rejects.toThrow(/已关闭/); + expect((await releaseOf(target.releaseId)).raw).toEqual(before.raw); + expect((await decisionOf(target.decisionId)).status).toBe(status); + expect(await eventsOf(target.releaseId)).toHaveLength(0); + }); + + it("事实与出处持久化,解析版本过期仍保留事实和规范季集,后来的明确解析优先", async () => { + const target = await seed({ title: "Silent Meridian S01E25", missing: ["resolution", "codec", "source", "group"] }); + await h.db + .updateTable("source_release") + .set({ + episode_resolution: JSON.stringify({ + method: "absolute_cross_season", + canonical: { season: 2, episodes: { start: 13, end: 13 } }, + }), + }) + .where("id", "=", target.releaseId) + .execute(); + const facts = { resolution: "1080p", videoCodec: "H.264", source: "BluRay", releaseGroup: "EvidenceTeam" } as const; + expect(await applyReleaseFacts(h.ctx, input(target, facts))).toEqual({ resolved: true, remaining: [] }); + const stored = await releaseOf(target.releaseId); + expect(stored.raw).toMatchObject({ + _site: { marker: "snapshot" }, + _detail: { imdbId: "tt1234567" }, + _facts: { + values: facts, + evidence: { resolution: { text: EVIDENCE, url: "https://facts.example.test/details.php?id=1" } }, + }, + }); + expect(parsedOf(stored)).toMatchObject({ + resolution: "1080p", + videoCodec: "H.264", + source: "BluRay", + isEncode: true, + releaseGroup: { name: "EvidenceTeam" }, + }); + await h.db.updateTable("source_release").set({ parser_version: "0" }).where("id", "=", target.releaseId).execute(); + expect(parsedOf(await releaseOf(target.releaseId))).toMatchObject({ + resolution: "1080p", + videoCodec: "H.264", + source: "BluRay", + releaseGroup: { name: "EvidenceTeam" }, + season: 2, + episodes: { start: 13, end: 13 }, + seasonInferred: false, + }); + const reparsed = toParsedColumns(parseRelease("Silent Meridian S02E13 2160p WEB-DL H.265")); + await h.db + .updateTable("source_release") + .set({ parsed: JSON.stringify(reparsed.parsed), parser_version: reparsed.parserVersion }) + .where("id", "=", target.releaseId) + .execute(); + expect(parsedOf(await releaseOf(target.releaseId))).toMatchObject({ + resolution: "2160p", + videoCodec: "H.265", + source: "WEB-DL", + isEncode: false, + }); + }); + + it("原生站点字段刷新后空补丁可以关闭事项,并触发原资源重评", async () => { + const target = await seed({ missing: ["size", "seeders"] }); + await h.db + .updateTable("source_release") + .set({ size_bytes: 400_000_000, seeders: 0 }) + .where("id", "=", target.releaseId) + .execute(); + expect(await applyReleaseFacts(h.ctx, input(target, {}))).toEqual({ resolved: true, remaining: [] }); + expect((await decisionOf(target.decisionId)).status).toBe("resolved"); + expect(await eventsOf(target.releaseId)).toEqual([ + expect.objectContaining({ + payload: expect.objectContaining({ + sourceReleaseId: target.releaseId, + mediaEntityId: entityId, + contentUnitIds: [], + }), + }), + ]); + expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("recovered"); + }); + + it("部分补全保持待处理,补齐后恢复;重复调用不会重复发重评事件", async () => { + const target = await seed({ missing: ["resolution", "codec"] }); + expect(await applyReleaseFacts(h.ctx, input(target))).toEqual({ resolved: false, remaining: ["codec"] }); + expect((await decisionOf(target.decisionId)).status).toBe("open"); + expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("pending"); + const finalInput = input(target, { videoCodec: "H.264" }); + expect(await applyReleaseFacts(h.ctx, finalInput)).toEqual({ resolved: true, remaining: [] }); + expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("recovered"); + const events = await eventsOf(target.releaseId); + expect(events).toHaveLength(2); + expect(await applyReleaseFacts(h.ctx, finalInput)).toEqual({ resolved: true, remaining: [] }); + expect(await eventsOf(target.releaseId)).toEqual(events); + }); + + it("部分完成后的同值补丁可以重试,保留事实和原出处;不同值仍拒绝", async () => { + const target = await seed({ missing: ["resolution", "size"] }); + const result = { resolved: false, remaining: ["size"] }; + expect(await applyReleaseFacts(h.ctx, input(target))).toEqual(result); + const stored = await releaseOf(target.releaseId); + const events = await eventsOf(target.releaseId); + expect(events).toHaveLength(1); + expect(stored.raw).toMatchObject({ + _facts: { + values: { resolution: "1080p" }, + evidence: { resolution: { text: EVIDENCE, url: "https://facts.example.test/details.php?id=1" } }, + }, + }); + + const retry = { + ...input(target), + evidence: "重试提供了另一段说明,不应覆盖第一次补全的出处。", + evidenceUrl: "https://facts.example.test/retry", + }; + expect(await applyReleaseFacts(h.ctx, retry)).toEqual(result); + expect((await releaseOf(target.releaseId)).raw).toEqual(stored.raw); + expect(await eventsOf(target.releaseId)).toEqual(events); + expect((await decisionOf(target.decisionId)).status).toBe("open"); + expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("pending"); + + await expect(applyReleaseFacts(h.ctx, input(target, { resolution: "2160p" }))).rejects.toThrow(/只能补全/); + expect((await releaseOf(target.releaseId)).raw).toEqual(stored.raw); + expect(parsedOf(await releaseOf(target.releaseId)).resolution).toBe("1080p"); + expect(await eventsOf(target.releaseId)).toEqual(events); + expect((await decisionOf(target.decisionId)).status).toBe("open"); + }); + + it("先补分辨率、后由原生详情补容量:空补丁完成时另发重评事件", async () => { + const target = await seed({ missing: ["resolution", "size"] }); + expect(await applyReleaseFacts(h.ctx, input(target))).toEqual({ resolved: false, remaining: ["size"] }); + const partialEvents = await eventsOf(target.releaseId); + expect(partialEvents).toHaveLength(1); + expect((await decisionOf(target.decisionId)).status).toBe("open"); + + await h.db + .updateTable("source_release") + .set({ size_bytes: 400_000_000 }) + .where("id", "=", target.releaseId) + .execute(); + expect(await applyReleaseFacts(h.ctx, input(target, {}))).toEqual({ resolved: true, remaining: [] }); + const completeEvents = await eventsOf(target.releaseId); + expect(completeEvents).toHaveLength(2); + expect(completeEvents.map((event) => event.idempotency_key)).not.toContain(null); + expect(new Set(completeEvents.map((event) => event.idempotency_key)).size).toBe(2); + expect((await decisionOf(target.decisionId)).status).toBe("resolved"); + expect(parsedOf(await releaseOf(target.releaseId))).toMatchObject({ resolution: "1080p", sizeBytes: 400_000_000 }); + expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("recovered"); + expect(await applyReleaseFacts(h.ctx, input(target, {}))).toEqual({ resolved: true, remaining: [] }); + expect(await eventsOf(target.releaseId)).toEqual(completeEvents); + }); + + it("补出的事实超出原收录范围时仍拒绝,事实能力不接受策略字段", async () => { + const target = await seed(); + const spec: SlotSpec = { name: "1080p", hard: { resolutions: ["1080p"] }, unknownPolicy: "hold" }; + const original = structuredClone(spec); + expect(evaluateCandidate(spec, parsedOf(await releaseOf(target.releaseId))).decision).toBe("unknown_field"); + await applyReleaseFacts(h.ctx, input(target, { resolution: "2160p" })); + expect(evaluateCandidate(spec, parsedOf(await releaseOf(target.releaseId)))).toMatchObject({ + decision: "reject", + violations: [{ field: "resolution", code: "not_allowed" }], + }); + expect(spec).toEqual(original); + expect( + ReleaseApplyFactsInput.safeParse({ ...input(target), facts: { resolution: "2160p", priority: 100 } }).success, + ).toBe(false); + expect(ReleaseApplyFactsInput.safeParse({ ...input(target), resolutions: ["2160p"] }).success).toBe(false); + }); + + it("无事实授权时只调查,身份授权和其他作品的事实授权不能代替", async () => { + const target = await seed(); + const investigation = await autoInvestigate(h.ctx, target.decisionId); + expect(investigation).toMatchObject({ created: true, mode: "investigate" }); + await expect(invoke(target, investigation.runId!)).rejects.toThrow(/授权/); + await grant(entityId, "policy.identity"); + await expect(invoke(target, investigation.runId!)).rejects.toThrow(/授权/); + await grant(anotherEntityId); + await expect(invoke(target, investigation.runId!)).rejects.toThrow(/授权|作品/); + expect(parsedOf(await releaseOf(target.releaseId)).resolution).toBeUndefined(); + expect(await eventsOf(target.releaseId)).toHaveLength(0); + }); + + it("资源事实会话只开放读取,有授权时只增加事实补全能力", async () => { + const readTarget = await seed(); + const read = await autoInvestigate(h.ctx, readTarget.decisionId); + expect(read).toMatchObject({ created: true, mode: "investigate" }); + const readRun = await h.db + .selectFrom("agent_run") + .selectAll() + .where("id", "=", read.runId!) + .executeTakeFirstOrThrow(); + const readCapabilities = (readRun.scope as { capabilities: string[] }).capabilities; + expect(readCapabilities).toEqual( + expect.arrayContaining(["release.inspect", "decision.list", "web.search", "web.fetch"]), + ); + expect(readCapabilities.filter((id) => CAPABILITY_CATALOG[id as KnownCapabilityId]?.kind !== "read")).toEqual([]); + expect(readCapabilities).not.toContain("release.apply_facts"); + + await grant(); + const recoverTarget = await seed(); + const recover = await autoInvestigate(h.ctx, recoverTarget.decisionId); + expect(recover).toMatchObject({ created: true, mode: "recover" }); + const recoverRun = await h.db + .selectFrom("agent_run") + .selectAll() + .where("id", "=", recover.runId!) + .executeTakeFirstOrThrow(); + const recoverCapabilities = (recoverRun.scope as { capabilities: string[] }).capabilities; + expect(recoverCapabilities.filter((id) => id !== "release.apply_facts").sort()).toEqual( + [...readCapabilities].sort(), + ); + expect(recoverCapabilities.filter((id) => CAPABILITY_CATALOG[id as KnownCapabilityId]?.kind !== "read")).toEqual([ + "release.apply_facts", + ]); + for (const id of [ + "identity.resolve", + "task.retry", + "download.recheck", + "episode_mapping.apply_rule", + "policy.apply", + "download.submit", + ]) { + expect(readCapabilities).not.toContain(id); + expect(recoverCapabilities).not.toContain(id); + } + }); + + it("有持续授权时仅写本次调查资源,恢复动作进入账本并按持久事实核实", async () => { + await grant(); + const target = await seed(); + const other = await seed(); + const investigation = await autoInvestigate(h.ctx, target.decisionId); + expect(investigation).toMatchObject({ created: true, mode: "recover" }); + await expect(invoke(other, investigation.runId!)).rejects.toThrow(/当前调查事项/); + expect(await invoke(target, investigation.runId!)).toEqual({ resolved: true, remaining: [] }); + const acts = await recoveryActions(h.ctx, investigation.runId!); + expect(acts.actions.filter((action) => action.status === "succeeded")).toEqual([ + expect.objectContaining({ + capabilityId: "release.apply_facts", + input: expect.objectContaining({ decisionId: target.decisionId, sourceReleaseId: target.releaseId }), + }), + ]); + expect((await checkRecovery(h.ctx, acts.actions)).state).toBe("recovered"); + expect(parsedOf(await releaseOf(target.releaseId)).resolution).toBe("1080p"); + expect(parsedOf(await releaseOf(other.releaseId)).resolution).toBeUndefined(); + expect((await decisionOf(other.decisionId)).status).toBe("open"); + expect(await eventsOf(target.releaseId)).toHaveLength(1); + expect(await eventsOf(other.releaseId)).toHaveLength(0); + }); + + it("事项状态单独为 resolved 不足以证明缺失事实已经恢复", async () => { + const target = await seed({ status: "resolved" }); + expect(parsedOf(await releaseOf(target.releaseId)).resolution).toBeUndefined(); + expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("pending"); + }); +}); diff --git a/apps/server/test/sources-identity/site-capabilities.test.ts b/apps/server/test/sources-identity/site-capabilities.test.ts index 585a5225..ecd7f770 100644 --- a/apps/server/test/sources-identity/site-capabilities.test.ts +++ b/apps/server/test/sources-identity/site-capabilities.test.ts @@ -15,8 +15,13 @@ const TiB = 1024 ** 4; const SITE_IDS = ["site.list", "site.health_check", "site.refresh_login", "site.user_data"] as const; describe("站点能力规格", () => { - it("进入能力目录(41 个已有能力 + 整理元数据补全 + 起草巡检 + 集数映射 3 个 + 指定整理作品);类别 / 风险 / 重放按约定", () => { - expect(CAPABILITY_IDS).toHaveLength(47); + it("进入能力目录(41 个已有能力 + 整理元数据补全 + 起草巡检 + 集数映射 3 个 + 指定整理作品 + 资源属性补全);类别 / 风险 / 重放按约定", () => { + expect(CAPABILITY_IDS).toHaveLength(48); + expect(capabilitySpec("release.apply_facts")).toMatchObject({ + kind: "execute", + riskLevel: "medium", + replay: "safe", + }); expect(capabilitySpec("episode_mapping.inspect")).toMatchObject({ kind: "read", replay: "safe" }); expect(capabilitySpec("episode_mapping.simulate")).toMatchObject({ kind: "preview", replay: "safe" }); expect(capabilitySpec("episode_mapping.apply_rule")).toMatchObject({ diff --git a/apps/server/test/sources-identity/site-center.test.ts b/apps/server/test/sources-identity/site-center.test.ts index 172f9520..80212af0 100644 --- a/apps/server/test/sources-identity/site-center.test.ts +++ b/apps/server/test/sources-identity/site-center.test.ts @@ -380,7 +380,7 @@ describe.skipIf(!up)("站点中心(馒头 API 基址)", () => { return new Response(JSON.stringify({ code: "0", data: { id: 7, username: "mt", memberCount: {} } }), { headers: { "content-type": "application/json" }, }); - }) as typeof globalThis.fetch; + }) as (input: RequestInfo | URL, init?: RequestInit) => Promise; const h = await createHarness("sitecenter_mtbase", { fetch }); try { const site = await json( diff --git a/apps/server/test/sources-identity/snapshot-deadline.test.ts b/apps/server/test/sources-identity/snapshot-deadline.test.ts index 21fb75cd..19a1f7b6 100644 --- a/apps/server/test/sources-identity/snapshot-deadline.test.ts +++ b/apps/server/test/sources-identity/snapshot-deadline.test.ts @@ -18,7 +18,10 @@ describe.skipIf(!up)("快照截止时间", () => { let h: Harness; let siteId: string; // 永不返回,也不理会 signal - const hang = (() => new Promise(() => {})) as typeof fetch; + const hang = (() => new Promise(() => {})) as ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise; beforeAll(async () => { h = await createHarness("snapdeadline", { fetch: hang }); diff --git a/apps/server/test/sources-identity/tmdb-language-policy.test.ts b/apps/server/test/sources-identity/tmdb-language-policy.test.ts index 753c90cc..06ecbd05 100644 --- a/apps/server/test/sources-identity/tmdb-language-policy.test.ts +++ b/apps/server/test/sources-identity/tmdb-language-policy.test.ts @@ -70,7 +70,7 @@ describe.skipIf(!up)("TMDB 多语言保留规则", () => { const actor = { type: "user" as const, id: "tester" }; const run = (...args: string[]) => - execFileSync(Deno.execPath(), ["run", "-A", SCRIPT, ...args], { + execFileSync(process.execPath, [SCRIPT, ...args], { cwd: ROOT, env: { ...process.env, DATABASE_URL: url, NO_COLOR: "1" }, encoding: "utf8", diff --git a/apps/server/test/storage/alist-organize.real.test.ts b/apps/server/test/storage/alist-organize.real.test.ts index ba706598..6c7d1203 100644 --- a/apps/server/test/storage/alist-organize.real.test.ts +++ b/apps/server/test/storage/alist-organize.real.test.ts @@ -11,15 +11,15 @@ import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from ". // 真实 OpenList 上跑一次整理往返(环境变量开启,缺省跳过;变量同 packages/adapters/src/alist/alist.real.test.ts): // 本地下载目录 → 整理(复制)到 OpenList 上的媒体库 → 从 OpenList 整理(移动)回本地媒体库 → OpenList 内移动。 -const URL_ = Deno.env.get("UMT_ALIST_REAL_URL"); -const ROOT = Deno.env.get("UMT_ALIST_REAL_ROOT"); -const auth: AlistAuth | null = Deno.env.get("UMT_ALIST_REAL_TOKEN") - ? { kind: "token", token: Deno.env.get("UMT_ALIST_REAL_TOKEN")! } - : Deno.env.get("UMT_ALIST_REAL_USER") +const URL_ = process.env.UMT_ALIST_REAL_URL; +const ROOT = process.env.UMT_ALIST_REAL_ROOT; +const auth: AlistAuth | null = process.env.UMT_ALIST_REAL_TOKEN + ? { kind: "token", token: process.env.UMT_ALIST_REAL_TOKEN! } + : process.env.UMT_ALIST_REAL_USER ? { kind: "password", - username: Deno.env.get("UMT_ALIST_REAL_USER")!, - password: Deno.env.get("UMT_ALIST_REAL_PASSWORD") ?? "", + username: process.env.UMT_ALIST_REAL_USER!, + password: process.env.UMT_ALIST_REAL_PASSWORD ?? "", } : null; const enabled = !!URL_ && !!ROOT && !!auth && (await pgAvailable()); diff --git a/apps/server/test/subscriptions/subscriptions-v2.test.ts b/apps/server/test/subscriptions/subscriptions-v2.test.ts index 8aceca38..e9577a0c 100644 --- a/apps/server/test/subscriptions/subscriptions-v2.test.ts +++ b/apps/server/test/subscriptions/subscriptions-v2.test.ts @@ -500,6 +500,104 @@ describe.skipIf(!hasPg)("订阅重做(真实 PG)", () => { expect(await db().selectFrom("slot_deferral").select("id").where("slot_id", "=", mq).execute()).toHaveLength(0); }); + it("收录规则按版本优先解析,补全继承主版本,概览包含各版本的实际引用", async () => { + const makeGroup = async (name: string, autoFill: boolean) => { + const r = await h.api("POST", "/rules", { name, ruleString: "1080P | 4K" }); + expect(r.status, JSON.stringify(r.body)).toBe(201); + await db().updateTable("rule_group").set({ auto_fill: autoFill }).where("id", "=", r.body.data.id).execute(); + return r.body.data.id as string; + }; + const legacy = await makeGroup("兼容订阅允许补全", true); + const primaryRule = await makeGroup("主版本暂停补全", false); + const e = await seedEntity(db(), { kind: "movie", title: "Converged Rules", year: 2026 }); + const s = await sub(e, { kind: "movie", params: { ruleGroupIds: [legacy], include: ["Converged"] } }); + const a = await asset(e, { quality: { resolution: "2160p" } }); + const primary = await slot(e, s, "4K", { + position: 0, + hard: { resolutions: ["2160p"] }, + currentAssetId: a, + status: "satisfied", + }); + await db() + .updateTable("acquisition_slot") + .set({ rule_group_ids: JSON.stringify([primaryRule]) }) + .where("id", "=", primary) + .execute(); + const secondary = await slot(e, s, "1080p", { position: 1, hard: { resolutions: ["1080p"] } }); + // 正常 API 保存共同过滤,生成私有组;它参与评估但不混入可见的规则名称。 + expect( + (await h.api("PATCH", `/subscriptions/${s}`, { params: { ruleGroupIds: [legacy], include: ["Converged"] } })) + .status, + ).toBe(200); + const r = await rel(e, "Converged.Rules.2026.1080p.WEB-DL.x264-GRP"); + const deferred = await evaluateAcquisition(h.d, { sourceReleaseId: r }); + expect(deferred.deferred).toContainEqual({ sourceReleaseId: r, slotId: secondary, reason: "auto_fill_off" }); + const overview = async () => (await h.api("GET", "/subscriptions?q=Converged%20Rules")).body.data.items[0]; + const before = await overview(); + expect(before.ruleGroups.map((g: { id: string }) => g.id)).toEqual([primaryRule, legacy]); + expect(before.slots.map((x: { ruleGroupIds: string[] }) => x.ruleGroupIds)).toEqual([[primaryRule], [legacy]]); + expect(before.autoFill).toMatchObject({ enabled: false, source: "rule_group" }); + await db().updateTable("rule_group").set({ auto_fill: true }).where("id", "=", primaryRule).execute(); + await db().updateTable("rule_group").set({ auto_fill: false }).where("id", "=", legacy).execute(); + expect((await overview()).autoFill).toMatchObject({ enabled: true, source: "rule_group" }); + const enabled = await evaluateAcquisition(h.d, { sourceReleaseId: r }); + expect(enabled.plans, JSON.stringify(enabled)).toHaveLength(1); + expect( + await db() + .selectFrom("acquisition_plan") + .select(["slot_id", "evaluation"]) + .where("id", "=", enabled.plans[0]!) + .executeTakeFirstOrThrow(), + ).toMatchObject({ + slot_id: secondary, + evaluation: { autoFill: true, primarySlotId: primary }, + }); + await h.api("PATCH", `/subscriptions/${s}`, { params: { ruleGroupIds: [legacy], autoFill: "off" } }); + expect((await overview()).autoFill).toMatchObject({ enabled: false, source: "subscription" }); + }); + + it("普通订阅保存保留旧质量过滤和每个版本独立的洗版设置", async () => { + const e = await seedEntity(db(), { kind: "series", title: "兼容编辑剧", year: 2026 }); + const params = { resolution: "4k", source: "uhd", effect: "atmos" }; + const s = await sub(e, { params }); + await slot(e, s, "1080p", { + hard: { resolutions: ["1080p"], codecs: ["H.265"] }, + upgrade: { enabled: false, mode: "per_unit" }, + }); + await slot(e, s, "4K", { hard: { resolutions: ["2160p"] }, upgrade: { enabled: true, mode: "full_pack" } }); + const slots = () => + db() + .selectFrom("acquisition_slot") + .select(["id", "revision", "hard_conditions", "upgrade_threshold"]) + .where("subscription_id", "=", s) + .orderBy("id") + .execute(); + const before = await slots(); + const saved = await h.api("PATCH", `/subscriptions/${s}`, { params: { ...params, searchIntervalHours: 6 } }); + expect(saved.status, JSON.stringify(saved.body)).toBe(200); + expect(saved.body.data.params).toEqual({ ...params, searchIntervalHours: 6 }); + expect(await slots()).toEqual(before); + const privateGroup = await db() + .selectFrom("rule_group") + .select("hard") + .where("scope", "@>", JSON.stringify({ subscriptionId: s })) + .where("deleted_at", "is", null) + .executeTakeFirstOrThrow(); + expect(JSON.stringify(privateGroup.hard)).toContain("Atmos"); + expect(JSON.stringify(privateGroup.hard)).toContain("UHD"); + const cleared = await h.api("PATCH", `/subscriptions/${s}`, { params: { searchIntervalHours: 6 } }); + expect(cleared.status, JSON.stringify(cleared.body)).toBe(200); + expect(await slots()).toEqual(before); + expect( + await db() + .selectFrom("rule_group") + .select("id") + .where("scope", "@>", JSON.stringify({ subscriptionId: s })) + .where("deleted_at", "is", null) + .execute(), + ).toEqual([]); + }); + it("表单:MP 字段与旧键兼容(规范化后只存规范键);校验 400;同作品同季 409;best_version → 升级策略", async () => { const e = await seedEntity(db(), { kind: "series", title: "表单剧", year: 2025 }); const site = await db() diff --git a/apps/server/test/support/pg.ts b/apps/server/test/support/pg.ts index 9e9f9b45..5cdb96d9 100644 --- a/apps/server/test/support/pg.ts +++ b/apps/server/test/support/pg.ts @@ -6,7 +6,7 @@ import pg from "pg"; export const PG_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt"; -/** 测试库连得上才跑 PG 用例;UMT_SKIP_DB_TESTS=1 显式跳过(deno task test:all 连不上库时直接失败) */ +/** 测试库连得上才跑 PG 用例;UMT_SKIP_DB_TESTS=1 显式跳过(bun run test:all 连不上库时直接失败) */ export async function pgAvailable(): Promise { if (process.env.UMT_SKIP_DB_TESTS === "1") return false; const c = new pg.Client({ connectionString: PG_URL, connectionTimeoutMillis: 2000 }); diff --git a/apps/server/test/system/bun-runtime.test.ts b/apps/server/test/system/bun-runtime.test.ts new file mode 100644 index 00000000..94366c91 --- /dev/null +++ b/apps/server/test/system/bun-runtime.test.ts @@ -0,0 +1,65 @@ +import { serve, upgradeWebSocket } from "@umt/runtime"; +import { Hono } from "hono"; +import { expect, it } from "vitest"; + +it("Bun HTTP 适配通过 Hono 中间件升级 WebSocket,保留协议、文本与二进制消息", async () => { + const app = new Hono(); + app.use("*", async (c, next) => { + await next(); + c.header("x-test", "middleware"); + }); + app.get("/ws", (c) => { + const { socket, response } = upgradeWebSocket(c.req.raw, { protocol: "umt-test" }); + socket.onmessage = (event) => socket.send(event.data); + return response; + }); + const server = serve({ hostname: "127.0.0.1", port: 0 }, app.fetch); + const client = new WebSocket(`ws://127.0.0.1:${server.addr.port}/ws`, ["umt-test"]); + client.binaryType = "arraybuffer"; + try { + await new Promise((resolve, reject) => { + client.onopen = () => resolve(); + client.onerror = () => reject(new Error("upgrade failed")); + }); + expect(client.protocol).toBe("umt-test"); + const echo = (data: string | ArrayBuffer) => + new Promise((resolve) => { + client.onmessage = (event) => resolve(event.data); + client.send(data); + }); + expect(await echo("hello")).toBe("hello"); + expect(new Uint8Array((await echo(new Uint8Array([1, 2, 3]).buffer)) as ArrayBuffer)).toEqual( + new Uint8Array([1, 2, 3]), + ); + } finally { + client.close(); + await server.shutdown(); + } +}); + +it("Bun WebSocket 上游连接发送自定义认证头", async () => { + let authorization: string | null = null; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(req, server) { + authorization = req.headers.get("authorization"); + if (server.upgrade(req)) return undefined; + return new Response(null, { status: 400 }); + }, + websocket: { message() {} }, + }); + const client = new WebSocket(`ws://127.0.0.1:${server.port}`, { + headers: { authorization: "Basic test" }, + } as unknown as string[]); + try { + await new Promise((resolve, reject) => { + client.onopen = () => resolve(); + client.onerror = () => reject(new Error("connect failed")); + }); + expect(authorization).toBe("Basic test"); + } finally { + client.close(); + await server.stop(true); + } +}); diff --git a/apps/server/test/system/proc-read.test.ts b/apps/server/test/system/proc-read.test.ts index ad3b416e..4027acb0 100644 --- a/apps/server/test/system/proc-read.test.ts +++ b/apps/server/test/system/proc-read.test.ts @@ -1,7 +1,8 @@ // 生产权限下读 /proc、/sys:Deno 2 不带 --allow-all 时 readTextFile 一律 NotCapable, // 编译产物的资源监控与沙箱内存看门狗曾因此静默取不到数(v0.3.34 运行状态页一直无数据)。 -// 这里用与 apps/server/deno.json compile 相同的权限起子进程,确认走 cat 兜底能读到。 +// 这里用与 apps/server/package.json compile 相同的权限起子进程,确认走 cat 兜底能读到。 import { fileURLToPath } from "node:url"; +import { Command } from "@umt/runtime"; import { describe, expect, it } from "vitest"; const here = fileURLToPath(new URL(".", import.meta.url)); @@ -13,20 +14,10 @@ const r = await readResources(); console.log(JSON.stringify({ stat: !!stat && stat.startsWith("cpu"), host: r.host != null, mem: r.meminfo != null })); `; -describe.skipIf(Deno.build.os !== "linux")("readProcText 在生产权限下", () => { +describe.skipIf(process.platform !== "linux")("readProcText 在生产权限下", () => { it("走 cat 兜底读到 /proc 与资源读数", async () => { - const out = await new Deno.Command(Deno.execPath(), { - args: [ - "run", - "--no-prompt", - "--allow-net", - "--allow-env", - "--allow-sys", - "--allow-read", - "--allow-write", - "--allow-run=ffprobe,sha256sum,cat", - "-", - ], + const out = await new Command(process.execPath, { + args: ["run", "-"], stdin: "piped", stdout: "piped", stderr: "piped", diff --git a/apps/server/test/system/system.test.ts b/apps/server/test/system/system.test.ts index 490b2eff..92373cc4 100644 --- a/apps/server/test/system/system.test.ts +++ b/apps/server/test/system/system.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import type { DiskUsage } from "@umt/adapters"; import { SystemHealth, SystemHealthHistory, SystemResources } from "@umt/contracts"; import { sql } from "@umt/db"; +import { runtimeMkdir, runtimeWriteTextFile } from "@umt/runtime"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { inferTriggeredBy, triggeredByFromActor } from "../../src/modules/auth-settings/engine-business.ts"; import { scheduleRegistry } from "../../src/modules/schedules/registry.ts"; @@ -233,8 +234,8 @@ describe.skipIf(!hasPg)("W9-I 进程与健康(真实 PG)", () => { ctx: h.ctx, }) as Harness["app"]; admin = bearer(h.app, DEV_KEY); - await Deno.mkdir(libOk, { recursive: true }); - await Deno.writeTextFile(join(libOk, ".umt-root"), "x"); + await runtimeMkdir(libOk, { recursive: true }); + await runtimeWriteTextFile(join(libOk, ".umt-root"), "x"); }, 60_000); afterAll(async () => { await h?.close(); diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index 75b2c5d2..7c6b5034 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -14,8 +14,6 @@ const UNIT = [ "test/search/work-match.test.ts", "test/auth.test.ts", "test/health.test.ts", - "test/hooks/hook-registry.test.ts", - "test/hooks/resource-selection.test.ts", "test/sources-identity/identity-differential.test.ts", "test/sources-identity/enrich-sources.test.ts", "test/invoke-limits.test.ts", diff --git a/bun.lock b/bun.lock new file mode 100644 index 00000000..2c62ae71 --- /dev/null +++ b/bun.lock @@ -0,0 +1,1506 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "uhd-media-tool", + "dependencies": { + "7z-wasm": "1.2.0", + "@babel/parser": "7.29.9", + "@biomejs/biome": "2.5.15", + "@earendil-works/chord": "1.1.0", + "@earendil-works/pi-ai": "1.1.0", + "@earendil-works/pi-durable": "1.1.0", + "@hono/swagger-ui": "0.6.1", + "@hono/zod-openapi": "1.6.3", + "@modelcontextprotocol/sdk": "1.31.0", + "@simplewebauthn/server": "14.0.3", + "@types/node": "24.19.2", + "@types/nunjucks": "3.2.6", + "@types/pg": "8.23.1", + "@umt/adapters": "workspace:*", + "@umt/agent": "workspace:*", + "@umt/contracts": "workspace:*", + "@umt/db": "workspace:*", + "@umt/domain": "workspace:*", + "@umt/mapping": "workspace:*", + "@umt/runtime": "workspace:*", + "@umt/tasks": "workspace:*", + "@umt/testkit": "workspace:*", + "@xmldom/xmldom": "0.9.12", + "cheerio": "1.2.0", + "fast-xml-parser": "5.11.2", + "fflate": "0.8.3", + "hash-wasm": "4.12.0", + "hono": "4.13.12", + "jpeg-js": "0.4.4", + "kysely": "0.29.6", + "node-unrar-js": "2.0.2", + "nunjucks": "3.2.4", + "openworkflow": "0.10.1", + "pg": "8.23.1", + "pino": "10.3.1", + "pino-pretty": "13.1.3", + "playwright-core": "1.63.0", + "postgres": "3.4.9", + "reflect-metadata": "0.2.2", + "typebox": "1.3.34", + "typescript": "7.0.2", + "vitest": "5.0.3", + "xpath": "0.0.34", + "yaml": "2.9.1", + "zod": "4.6.5", + }, + "devDependencies": { + "@types/bun": "1.4.3", + }, + }, + "apps/server": { + "name": "@umt/server", + "version": "0.3.44", + "dependencies": { + "@umt/adapters": "workspace:*", + "@umt/agent": "workspace:*", + "@umt/contracts": "workspace:*", + "@umt/db": "workspace:*", + "@umt/domain": "workspace:*", + "@umt/mapping": "workspace:*", + "@umt/runtime": "workspace:*", + "@umt/tasks": "workspace:*", + "@umt/testkit": "workspace:*", + }, + }, + "apps/web": { + "name": "@umt/web", + "version": "0.3.44", + "dependencies": { + "@fontsource-variable/inter-tight": "5.3.0", + "@fontsource-variable/space-grotesk": "5.3.0", + "@react-router/node": "8.4.0", + "@simplewebauthn/browser": "^14.0.0", + "@tanstack/react-query": "5.104.1", + "@umt/adapters": "workspace:*", + "@umt/agent": "workspace:*", + "@umt/contracts": "workspace:*", + "@umt/db": "workspace:*", + "@umt/domain": "workspace:*", + "@umt/mapping": "workspace:*", + "@umt/tasks": "workspace:*", + "@umt/testkit": "workspace:*", + "class-variance-authority": "^0.7.1", + "clsx": "2.1.1", + "date-fns": "4.4.0", + "isbot": "5.2.2", + "lucide-react": "1.55.0", + "next-themes": "^0.4.6", + "qr-code-styling": "1.9.2", + "radix-ui": "1.7.0", + "react": "19.3.0", + "react-day-picker": "10.0.2", + "react-dom": "19.3.0", + "react-router": "8.4.0", + "recharts": "3.10.1", + "simple-liquid-glass": "5.4.0", + "sonner": "2.0.8", + "tailwind-merge": "3.7.0", + "zod": "4.6.5", + }, + "devDependencies": { + "@react-router/dev": "8.4.0", + "@tailwindcss/vite": "4.3.3", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "tailwindcss": "4.3.3", + "tw-animate-css": "1.4.0", + "vite": "8.3.4", + "vite-tsconfig-paths": "6.1.1", + }, + }, + "packages/adapters": { + "name": "@umt/adapters", + "version": "0.3.44", + "dependencies": { + "@umt/contracts": "workspace:*", + "@umt/runtime": "workspace:*", + "@umt/testkit": "workspace:*", + }, + }, + "packages/agent": { + "name": "@umt/agent", + "version": "0.3.44", + "dependencies": { + "@umt/contracts": "workspace:*", + }, + }, + "packages/contracts": { + "name": "@umt/contracts", + "version": "0.3.44", + }, + "packages/db": { + "name": "@umt/db", + "version": "0.3.44", + "dependencies": { + "@umt/runtime": "workspace:*", + }, + }, + "packages/domain": { + "name": "@umt/domain", + "version": "0.3.44", + "dependencies": { + "@umt/contracts": "workspace:*", + }, + }, + "packages/mapping": { + "name": "@umt/mapping", + "version": "0.3.44", + "dependencies": { + "@umt/contracts": "workspace:*", + "@umt/domain": "workspace:*", + }, + }, + "packages/runtime": { + "name": "@umt/runtime", + "version": "0.3.44", + }, + "packages/tasks": { + "name": "@umt/tasks", + "version": "0.3.44", + "dependencies": { + "@umt/db": "workspace:*", + }, + }, + "packages/testkit": { + "name": "@umt/testkit", + "version": "0.3.44", + }, + }, + "packages": { + "7z-wasm": ["7z-wasm@1.2.0", "", { "dependencies": { "readline-sync": "^1.4.10" }, "bin": { "7z-wasm": "bin/cli" } }, "sha512-AZjtWXFleAe5nQ27JYISs70cICjO0zZr4LU45I9pXKcrhG0qiEWgq3Vjwr9JHHihFLuqgc7uSHYVn/z3vXCjdw=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.129.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw=="], + + "@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@9.1.0", "", { "dependencies": { "openapi3-ts": "^4.6.0" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-pLMeRgRYS7/vZIgAAkOe2P6+XGTifR1MT9pqDoxZ11EmcJJ+DPmdPqLN8ZZF4U8R9KHCCQCS9c2wtuE8wSrfkw=="], + + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1127.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-node": "^3.972.82", "@aws-sdk/eventstream-handler-node": "^3.972.34", "@aws-sdk/middleware-eventstream": "^3.972.29", "@aws-sdk/middleware-websocket": "^3.972.52", "@aws-sdk/token-providers": "3.1127.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.978.2", "", { "dependencies": { "@aws-sdk/types": "^3.974.6", "@aws-sdk/xml-builder": "^3.972.41", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.35.2", "@smithy/signature-v4": "^5.7.4", "@smithy/types": "^4.19.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-ViOBLuGSSyPAmkKGJ6KJXtVN7MOEp3rnI7uH+kWxnHccvo8C9cvunEAnN5/oaZH1laQfsTBw7IOnmNkWlFZl2A=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.73", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-PxlFZiF5IPUsPnuVSqjM9jlmRwZhL0xx40ccWfZiLQS8TRnHQpegpK1uAidatWuEqbJtbbY97A2Sezikz2AMUw=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.75", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-Q/uIDy8eAVN7JgSKDg16F/f+hmNJj/ZyLJN8AkW2ofVdDgJVh8fO/A26iZ7YsvM2/k76hsf/u2lLAtyXsK8Ziw=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.18", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/credential-provider-env": "^3.972.73", "@aws-sdk/credential-provider-http": "^3.972.75", "@aws-sdk/credential-provider-login": "^3.972.80", "@aws-sdk/credential-provider-process": "^3.972.73", "@aws-sdk/credential-provider-sso": "^3.973.17", "@aws-sdk/credential-provider-web-identity": "^3.972.79", "@aws-sdk/nested-clients": "^3.997.47", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/credential-provider-imds": "^4.5.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-KoyjZ9vdjuxEQ8obAH06xKP8H0WKqGhwUZLMqAOiS7G0xgUAht4X8f0ASLC/vhNLCWGblZOD3VQHWyTROGJHLg=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.80", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/nested-clients": "^3.997.47", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-xF9A0gaO7k+1dYPFoo69OQatA9Ov0QuDZfqE8H0FLyIoOph+mlEC+I1Qo9UULupGuDfSYh+kVpfcDSxSYf/2fg=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.85", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.73", "@aws-sdk/credential-provider-http": "^3.972.75", "@aws-sdk/credential-provider-ini": "^3.973.18", "@aws-sdk/credential-provider-process": "^3.972.73", "@aws-sdk/credential-provider-sso": "^3.973.17", "@aws-sdk/credential-provider-web-identity": "^3.972.79", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/credential-provider-imds": "^4.5.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-CrFcML/xImiSlquH4W3V6Npz3aSkPXHDVciAU1JOBN0vqjmkcz9H9up2sCHQqN76J/WrT+TJ580L8h2joksQrg=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.73", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-tyNnT4aVuOEXmKKKZPExBi828vRd9heshobBsKF0bov5EORHSJc1AGsHOMRP57sDt+dIuVASUOywaixiX26t4g=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.17", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/nested-clients": "^3.997.47", "@aws-sdk/token-providers": "3.1149.0", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-gxKTWaUk2gSbBOH4m3NPRfEKcGXeDAOgHr7nNG5JktR1xZLo0R+DpMjbcleoHbB0uJREOG7hjIRE635IuBWsRQ=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.79", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/nested-clients": "^3.997.47", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-/jn0Gs/qm6fs6nTIA5yncHayybz42SATZ3rNxCEBYPJ9v69PLrDRgk07E9xTT2gpRcNmDXXbi27ZbuU06N0Z6Q=="], + + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.36", "", { "dependencies": { "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-HFaKXA6ugPVvpR7W/3UT3hylWIPndqGMwduwNVrmGsRT5+OESHN8/ven4eeofraQF5Kxp8A8QgGVrv7iQsTRKQ=="], + + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.31", "", { "dependencies": { "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-4yvdTVwPGKCnrwuwlYEmwLqQXKwk0unx3f/FvBi4FA9ruK/WGoPJyqfK7hOYurheIyQL44FR0ovgOHqvkfdpAQ=="], + + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/signature-v4": "^5.7.4", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-dHlEl3sCn1Cu+qddPoz5RxjgGCXEzs/pbrh/rDRaOeb/Tz//FF59hacDvy7kvUn19mkm2nQRjhpzBnX+Ye37Qw=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.47", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/signature-v4-multi-region": "^3.996.48", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-bovCKvDsLocNTzaj6VrrMVJ2BGROqO0JlwcGseO67a+I9Bo+08nUJhSQfZUIx4+A4a7I4101GEJd/AYm7dQ68w=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.48", "", { "dependencies": { "@aws-sdk/types": "^3.974.6", "@smithy/signature-v4": "^5.7.4", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-cFDXfEbXaGwbnpvR/DyqfxfjxpjXVRVthQLgkwvcfDS5u3+8G9WSj1X1Ag/pTRg/QOeOno1Tq3ycEdrKsCUcOA=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1127.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.974.6", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.41", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.10", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.8" } }, "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg=="], + + "@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@babel/runtime": ["@babel/runtime@7.29.10", "", {}, "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.10", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@biomejs/biome": ["@biomejs/biome@2.5.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.15", "@biomejs/cli-darwin-x64": "2.5.15", "@biomejs/cli-linux-arm64": "2.5.15", "@biomejs/cli-linux-arm64-musl": "2.5.15", "@biomejs/cli-linux-x64": "2.5.15", "@biomejs/cli-linux-x64-musl": "2.5.15", "@biomejs/cli-win32-arm64": "2.5.15", "@biomejs/cli-win32-x64": "2.5.15" }, "bin": { "biome": "bin/biome" } }, "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.15", "", { "os": "win32", "cpu": "x64" }, "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g=="], + + "@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], + + "@earendil-works/chord": ["@earendil-works/chord@1.1.0", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-gsHzKfyQ3t0ZIQ3cLBjJ5Vqy1TYnFyruHIV3g3BwmulCDYe5BW4yT9vaJk9QzN6YANILIO75uzuq+Gdxtzz1og=="], + + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@1.1.0", "", { "dependencies": { "@anthropic-ai/sdk": "0.129.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "^1.1.0", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-1T7LAkc/5Bvc0v6w4vAGVdCrli0o/E0pEmYKTnixu95vSFArBjvbhS/G4ZwI0RUePgf0Imcu0VyqlM4EcXxqfw=="], + + "@earendil-works/pi-durable": ["@earendil-works/pi-durable@1.1.0", "", { "dependencies": { "@earendil-works/chord": "^1.1.0", "@earendil-works/pi-ai": "^1.1.0", "diff": "8.0.4", "typebox": "1.3.27" } }, "sha512-NtuB3RY24vQ+0/rWYpeHupyFWBfXuKq17to3VMYUYqfD/uuGnz6F6UTPBEm51VSxxA6LZwRcDwLX2LtRehnIgQ=="], + + "@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@1.1.0", "", {}, "sha512-8gAK05/2pPozZZz6hCLOi8x2vsqzvsmO9gG92kbvuvTPm0qMHaFlaXX98ndXsrOi5pmzha7vNLguuG4+0Rgxjw=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + + "@fontsource-variable/inter-tight": ["@fontsource-variable/inter-tight@5.3.0", "", {}, "sha512-ZhZZ29sNZ15P1K+cUt13GeBRYON0akgmNiftshUeDLos7X7zGcRgPzt434879Xhq1QbwlW3M/apBMk7jWabwvA=="], + + "@fontsource-variable/space-grotesk": ["@fontsource-variable/space-grotesk@5.3.0", "", {}, "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw=="], + + "@google/genai": ["@google/genai@2.21.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q=="], + + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], + + "@hono/node-server": ["@hono/node-server@2.1.4", "", { "peerDependencies": { "hono": "^4 || ^5.0.0-0" } }, "sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g=="], + + "@hono/swagger-ui": ["@hono/swagger-ui@0.6.1", "", { "peerDependencies": { "hono": ">=4.0.0" } }, "sha512-sJTvldu1GPeEPfyeLG7gRj+W4vEuD+JDi+JjJ3TJs/DvMUtBLs0KJO5yokGegWWdy5qrbdnQGekbhgNRmPmYKQ=="], + + "@hono/zod-openapi": ["@hono/zod-openapi@1.6.3", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^9.1.0", "@hono/zod-validator": "^0.9.1", "openapi3-ts": "^4.5.0" }, "peerDependencies": { "hono": ">=4.10.0", "zod": "^4.0.0" } }, "sha512-VYR82Y60plu8g3pN60OUXMVKpmxh3R04Qs++3DY9vtwZ9/sA/BRY0PcRIR+Z37/3itWtM7pYwT9bKiH9JhqV6Q=="], + + "@hono/zod-validator": ["@hono/zod-validator@0.9.1", "", { "peerDependencies": { "hono": ">=4.11.2", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-iiv6w0qrIc0arfvCtUqBWsvl4fXjzaTcQcCJTTtCnkawF9HHGE+KjEl0ox3gQJ6rKZEgE3mLExlQCF72M+mNuw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.31.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw=="], + + "@nodable/entities": ["@nodable/entities@3.1.0", "", {}, "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ=="], + + "@oxc-project/types": ["@oxc-project/types@0.153.0", "", {}, "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw=="], + + "@peculiar/asn1-android": ["@peculiar/asn1-android@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-IHKdYL5MRW76DOQCv2HSE/vUMOf2jqH4E7yhtQYe/xx+RxpPiXfkUdhex0N4/s5crsnXCGftRNzy4RJn1s6CZA=="], + + "@peculiar/asn1-asym-key": ["@peculiar/asn1-asym-key@2.10.0", "", { "dependencies": { "@peculiar/asn1-pkcs8": "^2.10.0", "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-1R8xUTeqcUTSMLFTTCzJyFuKiq3y25EQKs9DezUetJ8qMqbJyxo3XkzykiwwT/Xzlt5/vRzS/sW4M1r+pq1FTg=="], + + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "@peculiar/asn1-x509-attr": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg=="], + + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg=="], + + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.10.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.10.0", "@peculiar/asn1-pkcs8": "^2.10.0", "@peculiar/asn1-rsa": "^2.10.0", "@peculiar/asn1-schema": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA=="], + + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw=="], + + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.10.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.10.0", "@peculiar/asn1-pfx": "^2.10.0", "@peculiar/asn1-pkcs8": "^2.10.0", "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "@peculiar/asn1-x509-attr": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.10.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA=="], + + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg=="], + + "@peculiar/asn1-x509-post-quantum": ["@peculiar/asn1-x509-post-quantum@2.10.0", "", { "dependencies": { "@peculiar/asn1-asym-key": "^2.10.0", "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-cXKcZ30wKbUkRK5RK4Z3W1imewXvHAB9apEAuIqJ0OOkOQCpJrwLOuN38QlsEXqOI+3gtUv8Kf3UZohUmCbf3w=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/x509": ["@peculiar/x509@2.1.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.9.4", "@peculiar/asn1-csr": "^2.9.4", "@peculiar/asn1-ecc": "^2.9.4", "@peculiar/asn1-pkcs9": "^2.9.4", "@peculiar/asn1-rsa": "^2.9.4", "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "@peculiar/asn1-x509-post-quantum": "^2.9.4", "pvtsutils": "^1.3.6", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA=="], + + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + + "@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], + + "@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.16", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.12" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I+T/Hc6SY3WbFVZ9Ne1o0DJbpjQpTpLIBZcVzYiHCQ9z1yGUd/G88u5SKyM6eJ223txAOS7bp7bb9k0Ocfkjwg=="], + + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.21", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collapsible": "1.1.21", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2fmc6COuKLq2k3P7JZrOt9Zf8HT9nTQhHN5V+A3ih7ftqoyDcoEUrPoLq8ngr7QwTd7hpQ2xoUKWtvy1Xw9RAQ=="], + + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dialog": "1.2.0", "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dRvB59HhcjIWcCPKzK5Px1yQ8pIULQQrxqgyepCqin7tNXi/xvmC86WkyMOKhuCbjz3+v9Q6oE7/tUN5NQ9dCg=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.16", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nXPI1tzJsnQw8oXvB2r49rO9TIbbJ8VFeaWDiBsqY8+iwO5xb7WjUsrXDagjAgPKTdhW8WODQnAltUhBx8I+sg=="], + + "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.16", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-w2u6oiTzb7WkWwV3ZClX/llYYlTbilUOkHIKkwb+PWWytQ/IdEVRNLIChjbcXy/mkqcKKM3wEhrOilJW+uPHDg=="], + + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.2.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-6AizCpbU3Zngojp9vQak8Lt4AXwdKJ2lkRYAfpoETraT+qKVNeRqytiADzneHetFEB0Wu4+qeSfmZEq0PiVDmQ=="], + + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gNOS2lLrpQxNLWHhCX0sayfVRiyB5gQp7nQIzpl+PM1Hkab52utsKtD27alAr78uqhpvX51kwqDp71hM5hNSwg=="], + + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.21", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IzkusZHCZBnED5skW+b+syl8d+e3+zelvxZ2Ed9lfawqEBfa5osPj53sTJ+q/uNmGAj9e7wMYgyzwPu/luWJ4Q=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.16", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-slot": "1.4.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yEQrjfI5eBbeYEJXOASpm+gs9eNHY8+UjkZ81GzcuwdLrSMOlEM8jnGoGdeGo92oiBMkuwZUhHTsnsrDiJLcyA=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="], + + "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-menu": "2.1.25", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-s8ABBkEuAOyhDub0Y99f8lVRlVsd28mUnOHtuqGg8LuHnefkmPN5rYzA2g8MAPLjAZMisnDHhN4dBHUM7ZF7Rg=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.2.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.2.0", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-portal": "1.1.18", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-l5CX8HdNL+9cc4s7HkNyplumSA3FIx68sg9dttBHKr286fRBwq1MBniDVTeI/tkof92/APntMQvC1YG1Khb0Cg=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-/NlO/rOqQabmg+GGr86vCmbJVhqTv85NfM9g6Gknf/JVygXeZ6e6HM/RW1omerW9HsFtecwNqgEAKV6UqDsvpA=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-35WHRrycfnj2m4LvPiKMKHGLM3Y2WvUA744CkRdxjs1BGDX1c8PVMfmiSGTUwnHRbqdtIoxveYTXvcJSt7VfgQ=="], + + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.25", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-menu": "2.1.25", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-45QO76jn/a+cRl6OPgZKCKnIMdfYZaOa6SuAJeKHFiM3orX6bnscuFQJDx3qS4Agp/RE+eKv+9gmUL8t3Ma7bw=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.2.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I5S7vsAOozx2lWSBAyuXjWIJ/A8qx4fjbv6115WoaRYQSb1yZDaeNHonUAQzXyE8oD+0whJHbQRl4HIE2gQ0Xw=="], + + "@radix-ui/react-form": ["@radix-ui/react-form@0.2.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-label": "2.1.16", "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5ELeC+bQUHXEjU51svw11DynjGFlaUjo5QOUCr5hptvLLtBiqWtva2O3IWdjrFWQwitw6k6KpHHEyI61/kCVeg=="], + + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-focus-scope": "1.2.0", "@radix-ui/react-popper": "1.3.8", "@radix-ui/react-portal": "1.1.18", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0w/tZHRo1fv4WsfcyaOdi5eDftFA8wQkr8WCOOuxw7fx9S2ERfshQioM269aFZsmyAHd0X1LWoZ2BQTlA/JYUw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.16", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2vTQj72YFmeoTxWKJEMbO/9uQt6zzLgbzi0+nWBffq49oo6KhIDP/D+fA2im0wsxBCAX7QfaF3xgsYBVdDbNFQ=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.25", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.2.0", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.8", "@radix-ui/react-portal": "1.1.18", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-callback-ref": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-e8JaQc0ijBbTx37YVI09Nfq8ThUWJ4L1rvZ3Hvaufehco9/ug9+U3PHz9ue6w3gKd91UcKf9fgDWejeSO+VSKQ=="], + + "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.25", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-menu": "2.1.25", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JIQiKcXsgl6BtwcMe7BMLvCGH2p9Czgcmxl6Ie9Zoi+MW1mmitkcm5We01m/ndLYVI8PpRJnM/RcLV0eOCxaJQ=="], + + "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.3.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.12" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-hZqTqDwc8Z5aI4rGIyPqCSJ2JoSNorqX/F1/gqNHkZzWJO6JuYzL7hn5sWOZJjpkfT+cbxWWRbKVU/IeiOM9ag=="], + + "@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.17", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+6jmOqjSNBr7F1uSc7HSDqD3XL54A2qA99I9rIH4JoOD/jDScw8tUmDz6HCAkjytbrE/cdL66i+QkeDyZ0WNyw=="], + + "@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-is-hydrated": "0.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+SWSgf3HRpE4zUira+p6t5KUR473PSqewtQgj9qzl3znZPRWPCGycwXNTfHwkiRTD3tEXwNIXJg0i1NTteiULw=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.2.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.2.0", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.8", "@radix-ui/react-portal": "1.1.18", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-e70Nh+0iQ+hRAIM2nAGib5BhaDL2Rjlzxw9kIzsU2nz+t3/t82Z5qtPhY5nTnfj8FbmrU6RRIKH8RF3ynCe+vw=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.5", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-y92/9iIdG1FyNvow0Y0qP5WJZ7oRTAKDVKCy9kJnhZfItwIKYtt0KKQeOhBKfXz0ETizduKwDRbrQHrTQScs5A=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.18", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ygFIJ+QXNKf72AAq8x3hd22+ws7pYpmc1S0E4kW0Je+qD4qEb3FvxeKVQRGrVDMOkQaYfsB+Qs+KDh81a1gAOw=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.11", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.11", "", { "dependencies": { "@radix-ui/react-slot": "1.4.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-RzcaSw5aKl0OPhBJ1wbolKSF6nVvN+0o/QLeIVq+09npiPdnCkPggy2k6gI6yetxl4ZyrlBPYFgCB8BQJZwXcQ=="], + + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.17", "", { "dependencies": { "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-A+RfMARd9u+UaVbLQDmysGlAGuaFQrF+uxF3v+7+xU8tkKoYxtCSs+6HHnIKJVgam2sGZ7c0hLRPWSnmnFyGJQ=="], + + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MaOOZ1TlaYoOeSDnHeVIONss0rp5ngOTY5HJY+Xkq16PAopnj21iU9r9m0O/77cBYu8Ksaq3BcjlaOdpbfbzzg=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+ZjbkU21fTSJdn4nq/IFKUdKS+JSZHORBZ8ma1f6YpOMNzRt0xf3sVAWVK/+ktHCajPy4HYDsyHXZIZmOMxzIA=="], + + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.3.0", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1WSuAXaahfeLmFX/dfYo/ftg1filMfaWjVye2d40VlgtWMMq/NOVocfukZEXkG+dwrAoNI+qpo9bjhNSJ/XC8g=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.3.8", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.2.0", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.8", "@radix-ui/react-portal": "1.1.18", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.12", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Tz9xOpV9Rbca5WTk7pzmfAECV3zb++uScT9mP3Px7ziAhH7SlK53b0uwMw8VubPhRK4x7uDKCy5fwevT8I0fAg=="], + + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.16", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vugPjGQBXbJVVkftKqv/BVeZ108SyRGT3FR1oa14az6qyLgX8jPhnZYHgdSB7Gw8UJusqwb9eaK6mluOnsn/Vg=="], + + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.5.0", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-use-size": "1.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ZOBLK7KZvGL02V+4VwVbj8NYM4vPrAtyZdrgHSv+AMS9VL+5yAdnQF3DZT4Y8cbCp1Yn4xdp92orvez8fJa77w=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.4.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-IuMVHZ+Ah5fkctu3X3qeFrV/ZrBcuWuDTFUUPfS/L5hvoTjzhp/HdWGE14CsQombPdEidsa33HVlYZXi49PZ1Q=="], + + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-rr+zuoAeHWK3m6H4HgjOUiu3VsrQ/9JkjwY/uMnTgHJGPe9WNzDP/MOuIV7S+65p42DIxjGL0hAHktqBq82+2A=="], + + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.22", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GmVH+P3wx1ZPMuH7WajQzygZIbqgnsb5h8z4Ow3sQMuDqgcqISSBNom6MLVNJvfrkuJTtqfXc3ZolHjX+w0CHg=="], + + "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-portal": "1.1.18", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.12" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XR9pDEz9CH0ez98zPXQZOpF46anvbarkoOpjXpPSX7gm21vEl5Tzoe1PSQd3EY1OTOHac4lNq+TPx/2Z7jObdQ=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-l7FQHtuZjUJ0vKLdhKu5LzZ8Jw6K60QEC1tC22QNLuXN2PkydSoqEhKTCqwxBFZJ52NTCjzudDvl9sUsZeUbPw=="], + + "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-toggle": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O+Sjiv64DfnByHYtnKEXmn63uUskU/X61vjf69X1v8JFn/ZvuNNLdJTBfYurVTSDv4/RJnJGtGgb/uvuS/rRJw=="], + + "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-separator": "1.1.16", "@radix-ui/react-toggle-group": "1.1.20" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5gyzL6B5/zjBwU3ATBWAVZ4xXQC5F8E9CB4BmYC1UvZ08pN2OJQoooeIwJtZZ/2rz4sTiQmbGfdDvmU73wRJCQ=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.3.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.8", "@radix-ui/react-portal": "1.1.18", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.12" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4HM4b1Cft1CBXCGDRDG3S/fh8GedBuVOMNh145wMIfTbVqcuv+tyj03nZqYgFevWJMYo58H0s+8P6s6NaUHWtA=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.5", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA=="], + + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.4", "", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.12", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/iPLVHRBzKWvekN+5xaDmXw80f+nleYYceomVORrGjuJe0LF2SCbSH4f/LG4hu32q92QSuPZx4hmSACYBVBfzQ=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="], + + "@react-router/dev": ["@react-router/dev@8.4.0", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@react-router/node": "8.4.0", "@remix-run/node-fetch-server": "^0.14.1", "babel-dead-code-elimination": "^1.0.12", "chokidar": "^5.0.0", "dedent": "^1.7.2", "es-module-lexer": "^2.1.0", "exit-hook": "5.1.0", "isbot": "^5.1.40", "jsesc": "3.1.0", "lodash": "^4.18.1", "p-map": "^7.0.4", "pathe": "^2.0.3", "picocolors": "^1.1.1", "pkg-types": "^2.3.1", "react-refresh": "^0.18.0", "semver": "^7.8.1", "tinyglobby": "^0.2.16", "valibot": "^1.4.1" }, "peerDependencies": { "@react-router/serve": "^8.4.0", "@vitejs/plugin-rsc": "~0.5.26", "react-router": "^8.4.0", "react-server-dom-webpack": "^19.2.7", "typescript": "^5.1.0 || ^6.0.0 || ^7.0.0", "vite": "^7.0.0 || ^8.0.0", "wrangler": "^4.0.0" }, "optionalPeers": ["@react-router/serve", "@vitejs/plugin-rsc", "react-server-dom-webpack", "typescript", "wrangler"], "bin": { "react-router": "bin.cjs" } }, "sha512-GKznpn6h8c52xGXwtsypD5ZwNiWFSeqoKPdyGq7SfG+T9rFjGO8F0i/R/yECfZqHXaNz1NVmpODuBamBy9FfMg=="], + + "@react-router/node": ["@react-router/node@8.4.0", "", { "dependencies": { "@remix-run/node-fetch-server": "^0.14.1" }, "peerDependencies": { "react-router": "8.4.0", "typescript": "^5.1.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-1teZHtk7XDm1pteQ2Plgtvtjjup5C5sIwFlOMYVIyoOH7X3bId3Qb//kUdbydX6NRIxcGK28UnfZlz4SrD/mkg=="], + + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.13.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA=="], + + "@remix-run/node-fetch-server": ["@remix-run/node-fetch-server@0.14.2", "", {}, "sha512-1cpM/f5i1ka/9CUMBwZXZp7Bn92x8VQjzg+F4eEe88DlVdNHkAKrf/vvbZoYgdkfy2bWHtGC6LQGLmojEqOF4Q=="], + + "@remix-run/route-pattern": ["@remix-run/route-pattern@0.22.1", "", {}, "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.13", "", { "os": "android", "cpu": "arm" }, "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.13", "", { "os": "android", "cpu": "arm64" }, "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.13", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.13", "", { "os": "linux", "cpu": "arm" }, "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.13", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.13", "", { "os": "linux", "cpu": "s390x" }, "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.13", "", { "os": "linux", "cpu": "x64" }, "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.13", "", { "os": "linux", "cpu": "x64" }, "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.13", "", { "os": "none", "cpu": "arm64" }, "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.13", "", { "os": "win32", "cpu": "x64" }, "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@simplewebauthn/browser": ["@simplewebauthn/browser@14.0.0", "", {}, "sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow=="], + + "@simplewebauthn/server": ["@simplewebauthn/server@14.0.3", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-post-quantum": "^2.9.4", "@peculiar/x509": "^2.1.0", "reflect-metadata": "^0.2.2" } }, "sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q=="], + + "@smithy/core": ["@smithy/core@3.35.2", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-ejTukBuP/Pg3t9N8Ux+0zKsXeHWm7p6W3HbXFOyltKasSxZwCFmvTut7kBdZSCF3m9y1bXXyVu6p2xuMFcsTyQ=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.8.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.7.4", "", { "dependencies": { "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ=="], + + "@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.104.1", "", {}, "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.104.1", "", { "dependencies": { "@tanstack/query-core": "5.104.1" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ=="], + + "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.2.0", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/node": ["@types/node@24.19.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ=="], + + "@types/nunjucks": ["@types/nunjucks@3.2.6", "", {}, "sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w=="], + + "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], + + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@umt/adapters": ["@umt/adapters@workspace:packages/adapters"], + + "@umt/agent": ["@umt/agent@workspace:packages/agent"], + + "@umt/contracts": ["@umt/contracts@workspace:packages/contracts"], + + "@umt/db": ["@umt/db@workspace:packages/db"], + + "@umt/domain": ["@umt/domain@workspace:packages/domain"], + + "@umt/mapping": ["@umt/mapping@workspace:packages/mapping"], + + "@umt/runtime": ["@umt/runtime@workspace:packages/runtime"], + + "@umt/server": ["@umt/server@workspace:apps/server"], + + "@umt/tasks": ["@umt/tasks@workspace:packages/tasks"], + + "@umt/testkit": ["@umt/testkit@workspace:packages/testkit"], + + "@umt/web": ["@umt/web@workspace:apps/web"], + + "@vitest/mocker": ["@vitest/mocker@5.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.3", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg=="], + + "@vitest/spy": ["@vitest/spy@5.0.3", "", {}, "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg=="], + + "@xmldom/xmldom": ["@xmldom/xmldom@0.9.12", "", {}, "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A=="], + + "a-sync-waterfall": ["a-sync-waterfall@1.0.1", "", {}, "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.28", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + + "browserslist": ["browserslist@4.29.3", "", { "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001815", "", {}, "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q=="], + + "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], + + "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], + + "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + + "commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + + "confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="], + + "content-disposition": ["content-disposition@2.0.1", "", {}, "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], + + "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + + "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.452", "", {}, "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es-toolkit": ["es-toolkit@1.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="], + + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + + "exit-hook": ["exit-hook@5.1.0", "", {}, "sha512-INjr2xyxHo7bhAqf5ong++GZPPnpcuBcaXUKt03yf7Fie9yWD7FapL4teOU0+awQazGs5ucBh7xWs/AD+6nhog=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "express": ["express@5.3.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.3.0", "content-disposition": "^2.0.1", "content-type": "^2.0.0", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.8", "qs": "^6.16.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.1.0", "vary": "^1.1.2" } }, "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g=="], + + "express-rate-limit": ["express-rate-limit@8.7.1", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA=="], + + "exsolve": ["exsolve@1.1.3", "", {}, "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-copy": ["fast-copy@4.1.2", "", {}, "sha512-DUHggaYcouhy5I1LacDJ+JDSqGlumVJP2EAi5Nwt1pWPeLpSvIjkrY+x4fsoI1/+GlTyq+DM9zdgWCFSfqNODA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], + + "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], + + "fast-xml-parser": ["fast-xml-parser@5.11.2", "", { "dependencies": { "@nodable/entities": "^3.0.1", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gaxios": ["gaxios@7.3.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + + "google-auth-library": ["google-auth-library@10.9.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hash-wasm": ["hash-wasm@4.12.0", "", {}, "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + + "hono": ["hono@4.13.12", "", {}, "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw=="], + + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "http-proxy-agent": ["http-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig=="], + + "https-proxy-agent": ["https-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "immer": ["immer@11.1.21", "", {}, "sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "ip-address": ["ip-address@10.7.4", "", {}, "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], + + "isbot": ["isbot@5.2.2", "", {}, "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "kysely": ["kysely@0.29.6", "", {}, "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-react": ["lucide-react@1.55.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-bSozQHiI3/8q7JvgoqVRGOYUi7fYT3siAZwmZsljjsOH2Husp/2ODdFf/leD+ZC/2yKfn7r8oh6yeoR3ITR5vA=="], + + "magic-string": ["magic-string@1.4.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "node-releases": ["node-releases@2.0.58", "", {}, "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg=="], + + "node-unrar-js": ["node-unrar-js@2.0.2", "", {}, "sha512-hLNmoJzqaKJnod8yiTVGe9hnlNRHotUi0CreSv/8HtfRi/3JnRC8DvsmKfeGGguRjTEulhZK6zXX5PXoVuDZ2w=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "nunjucks": ["nunjucks@3.2.4", "", { "dependencies": { "a-sync-waterfall": "^1.0.0", "asap": "^2.0.3", "commander": "^5.1.0" }, "peerDependencies": { "chokidar": "^3.3.0" }, "optionalPeers": ["chokidar"], "bin": { "nunjucks-precompile": "bin/precompile" } }, "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "openai": ["openai@7.19.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "undici": ">=5 <9", "ws": "^8.21.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "undici", "ws", "zod"] }, "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ=="], + + "openapi3-ts": ["openapi3-ts@4.6.1", "", { "dependencies": { "yaml": "^2.9.0" } }, "sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA=="], + + "openworkflow": ["openworkflow@0.10.1", "", { "peerDependencies": { "@opentelemetry/api": "1", "postgres": "^3.4.9" }, "optionalPeers": ["@opentelemetry/api", "postgres"] }, "sha512-BdgEjoaazhL26JgQ/SehD/tLVEUYsHD6oZWU79+04jnxc+hPlxmRRfVfaAGeUdS2YEPFr/53C0kcRNodzAdNnA=="], + + "p-map": ["p-map@7.1.1", "", {}, "sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g=="], + + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], + + "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], + + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pg": ["pg@8.23.1", "", { "dependencies": { "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.1", "", {}, "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ=="], + + "pg-connection-string": ["pg-connection-string@2.14.1", "", {}, "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.16.1", "", {}, "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], + + "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + + "pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "pkg-types": ["pkg-types@2.3.3", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + + "postcss": ["postcss@8.5.29", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="], + + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "process-warning": ["process-warning@5.1.1", "", {}, "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg=="], + + "protobufjs": ["protobufjs@7.6.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg=="], + + "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], + + "proxy-agent-negotiate": ["proxy-agent-negotiate@1.1.0", "", { "peerDependencies": { "kerberos": "^2.0.0" }, "optionalPeers": ["kerberos"] }, "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], + + "qr-code-styling": ["qr-code-styling@1.9.2", "", { "dependencies": { "qrcode-generator": "^1.4.4" } }, "sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg=="], + + "qrcode-generator": ["qrcode-generator@1.5.2", "", {}, "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw=="], + + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], + + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + + "radix-ui": ["radix-ui@1.7.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-accessible-icon": "1.1.16", "@radix-ui/react-accordion": "1.2.21", "@radix-ui/react-alert-dialog": "1.1.24", "@radix-ui/react-arrow": "1.1.16", "@radix-ui/react-aspect-ratio": "1.1.16", "@radix-ui/react-avatar": "1.2.7", "@radix-ui/react-checkbox": "1.3.12", "@radix-ui/react-collapsible": "1.1.21", "@radix-ui/react-collection": "1.1.16", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-context-menu": "2.3.8", "@radix-ui/react-dialog": "1.2.0", "@radix-ui/react-direction": "1.1.5", "@radix-ui/react-dismissable-layer": "1.1.20", "@radix-ui/react-dropdown-menu": "2.1.25", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.2.0", "@radix-ui/react-form": "0.2.0", "@radix-ui/react-hover-card": "1.1.24", "@radix-ui/react-label": "2.1.16", "@radix-ui/react-menu": "2.1.25", "@radix-ui/react-menubar": "1.1.25", "@radix-ui/react-navigation-menu": "1.3.0", "@radix-ui/react-one-time-password-field": "0.1.17", "@radix-ui/react-password-toggle-field": "0.1.12", "@radix-ui/react-popover": "1.2.0", "@radix-ui/react-popper": "1.3.8", "@radix-ui/react-portal": "1.1.18", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.1.11", "@radix-ui/react-progress": "1.1.17", "@radix-ui/react-radio-group": "1.4.8", "@radix-ui/react-roving-focus": "1.1.20", "@radix-ui/react-scroll-area": "1.3.0", "@radix-ui/react-select": "2.3.8", "@radix-ui/react-separator": "1.1.16", "@radix-ui/react-slider": "1.5.0", "@radix-ui/react-slot": "1.4.0", "@radix-ui/react-switch": "1.3.8", "@radix-ui/react-tabs": "1.1.22", "@radix-ui/react-toast": "1.2.24", "@radix-ui/react-toggle": "1.1.19", "@radix-ui/react-toggle-group": "1.1.20", "@radix-ui/react-toolbar": "1.1.20", "@radix-ui/react-tooltip": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-escape-keydown": "1.1.5", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-size": "1.1.5", "@radix-ui/react-visually-hidden": "1.2.12" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-x+z5TV2v6u0QpAQ4dQfNMcvtIdSQ1Op2oqBEyfDXjNldRZbMTP+hcjr5Iie31bep81Axeioa+70Ww7pZDzTEQQ=="], + + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + + "react-day-picker": ["react-day-picker@10.0.2", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "react": ">=16.8.0" }, "optionalPeers": ["@types/react"] }, "sha512-QetiKtZWE9Uaw/pM/SYNv6WPYOTRnSJ2puL0GgwkmUFMIwiY0dtjH1W4B7DOVXVNQM0ZOClGpH/heOfBSzWeiA=="], + + "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + + "react-is": ["react-is@19.3.0", "", {}, "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA=="], + + "react-redux": ["react-redux@9.3.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-router": ["react-router@8.4.0", "", { "dependencies": { "@remix-run/route-pattern": "^0.22.1", "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], + + "readline-sync": ["readline-sync@1.4.10", "", {}, "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw=="], + + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + + "recharts": ["recharts@3.10.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^11.1.8", "react-redux": "8.x.x || 9.x.x", "reselect": "5.2.0", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA=="], + + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + + "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + + "rolldown": ["rolldown@1.2.13", "", { "dependencies": { "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.13", "@rolldown/binding-android-arm64": "1.2.13", "@rolldown/binding-darwin-arm64": "1.2.13", "@rolldown/binding-darwin-x64": "1.2.13", "@rolldown/binding-freebsd-x64": "1.2.13", "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", "@rolldown/binding-linux-arm64-gnu": "1.2.13", "@rolldown/binding-linux-arm64-musl": "1.2.13", "@rolldown/binding-linux-ppc64-gnu": "1.2.13", "@rolldown/binding-linux-s390x-gnu": "1.2.13", "@rolldown/binding-linux-x64-gnu": "1.2.13", "@rolldown/binding-linux-x64-musl": "1.2.13", "@rolldown/binding-openharmony-arm64": "1.2.13", "@rolldown/binding-win32-arm64-msvc": "1.2.13", "@rolldown/binding-win32-x64-msvc": "1.2.13" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "secure-json-parse": ["secure-json-parse@4.1.1", "", {}, "sha512-lnd81MQEZKjhW/Gd2TQqdd4XP6QYNdDW/EP+I7nTrFHP9y2LmaGPIHk9xsnzcOiEr1c39c/e84xFgGviQKBTRw=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "simple-liquid-glass": ["simple-liquid-glass@5.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-WSkyOI8RTQvUAZGjWeyNmDA+Mf3RSC9HbgfBmNe99K1PWVz86aN3GVCQQiIg0h94guWVlxjRReJNNNxO3ghAzA=="], + + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + + "sonner": ["sonner@2.0.8", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], + + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + + "strnum": ["strnum@2.5.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA=="], + + "tailwind-merge": ["tailwind-merge@3.7.0", "", {}, "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tinybench": ["tinybench@6.2.2", "", {}, "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + + "typebox": ["typebox@1.3.34", "", {}, "sha512-wbnzrXXDW8xEFHDZZs2jo1MkhaYlKAY4FRhpBc1+2LF1fZVBGCXGdLEhA/Z/NBbgzJMFfeM8m7elKPa/+KxaUQ=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici": ["undici@7.30.0", "", {}, "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.3.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], + + "valibot": ["valibot@1.5.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + + "vite": ["vite@8.3.4", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.29", "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ=="], + + "vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="], + + "vitest": ["vitest@5.0.3", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.3", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "^6.1.4", "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.3", "@vitest/browser-preview": "5.0.3", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.3", "@vitest/coverage-v8": "5.0.3", "@vitest/ui": "5.0.3", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@3.2.1", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + + "xpath": ["xpath@0.0.34", "", {}, "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1149.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/nested-clients": "^3.997.47", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-CAwYyA8eVk1w6h8YgLvkjIOakWPwURaUFrP7BRVgkxTFUM+O87thyKsGKJ6rVPbP8eoEV10cBWQ5YfaYl3qSVQ=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@earendil-works/pi-ai/typebox": ["typebox@1.3.27", "", {}, "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA=="], + + "@earendil-works/pi-durable/typebox": ["typebox@1.3.27", "", {}, "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA=="], + + "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "bun-types/undici-types": ["undici-types@8.11.2", "", {}, "sha512-iMVNmWZ0leK/goS6eXMizSzmm9CDWtyphwbaCms3DNLqRxDL+mMoNVcZMTyyVgXP0N+Z8neAMzDoUOUJL8veKg=="], + + "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "express/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + } +} diff --git a/config/tsconfig.base.json b/config/tsconfig.base.json index 3d03c64d..f0c53fd2 100644 --- a/config/tsconfig.base.json +++ b/config/tsconfig.base.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { - "target": "ES2023", - "lib": ["ES2023"], + "target": "ES2024", + "lib": ["ES2024", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", "moduleDetection": "force", @@ -18,8 +18,6 @@ "esModuleInterop": true, "skipLibCheck": true, "noEmit": true, - "types": ["node"] - }, - // deno types 生成的 Deno 全局声明(`deno task types`,gitignored);tsc 7 靠它认识 Deno.* - "files": ["../types/deno.d.ts"] + "types": ["node", "bun"] + } } diff --git a/deno.json b/deno.json deleted file mode 100644 index b4cc7ab2..00000000 --- a/deno.json +++ /dev/null @@ -1,114 +0,0 @@ -{ - "version": "0.3.44", - "workspace": [ - "./packages/adapters", - "./packages/agent", - "./packages/contracts", - "./packages/db", - "./packages/domain", - "./packages/mapping", - "./packages/tasks", - "./packages/testkit", - "./apps/server", - "./apps/web" - ], - "nodeModulesDir": "auto", - "imports": { - "@biomejs/biome": "npm:@biomejs/biome@2.5.15", - "@earendil-works/chord": "npm:@earendil-works/chord@1.1.0", - "@earendil-works/chord/": "npm:/@earendil-works/chord@1.1.0/", - "@earendil-works/pi-ai": "npm:@earendil-works/pi-ai@1.1.0", - "@earendil-works/pi-ai/": "npm:/@earendil-works/pi-ai@1.1.0/", - "@earendil-works/pi-durable": "npm:@earendil-works/pi-durable@1.1.0", - "@earendil-works/pi-durable/": "npm:/@earendil-works/pi-durable@1.1.0/", - "@hono/swagger-ui": "npm:@hono/swagger-ui@0.6.1", - "@hono/zod-openapi": "npm:@hono/zod-openapi@1.6.3", - "@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@1.31.0", - "@modelcontextprotocol/sdk/": "npm:/@modelcontextprotocol/sdk@1.31.0/", - "@simplewebauthn/server": "npm:@simplewebauthn/server@14.0.3", - "@types/node": "npm:@types/node@24.19.0", - "@types/nunjucks": "npm:@types/nunjucks@3.2.6", - "@types/pg": "npm:@types/pg@8.23.1", - "@xmldom/xmldom": "npm:@xmldom/xmldom@0.9.12", - "cheerio": "npm:cheerio@1.2.0", - "fast-xml-parser": "npm:fast-xml-parser@5.11.2", - "fflate": "npm:fflate@0.8.3", - "hash-wasm": "npm:hash-wasm@4.12.0", - "hono": "npm:hono@4.13.12", - "hono/": "npm:/hono@4.13.12/", - "jpeg-js": "npm:jpeg-js@0.4.4", - "kysely": "npm:kysely@0.29.6", - "kysely/": "npm:/kysely@0.29.6/", - "node-unrar-js": "npm:node-unrar-js@2.0.2", - "nunjucks": "npm:nunjucks@3.2.4", - "openworkflow": "npm:openworkflow@0.10.1", - "openworkflow/": "npm:/openworkflow@0.10.1/", - "pg": "npm:pg@8.23.1", - "7z-wasm": "npm:7z-wasm@1.2.0", - "pino": "npm:pino@10.3.1", - "pino-pretty": "npm:pino-pretty@13.1.3", - "playwright-core": "npm:playwright-core@1.63.0", - "postgres": "npm:postgres@3.4.9", - "typebox": "npm:typebox@1.3.34", - "typescript": "npm:typescript@7.0.2", - "vitest": "npm:vitest@5.0.3", - "vitest/": "npm:/vitest@5.0.3/", - "xpath": "npm:xpath@0.0.34", - "yaml": "npm:yaml@2.9.1", - "zod": "npm:zod@4.6.5" - }, - "compilerOptions": { - "strict": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "noFallthroughCasesInSwitch": true, - "verbatimModuleSyntax": true - }, - "tasks": { - "dev": { - "description": "同时起 server(4380)与 web(5180)", - "dependencies": ["dev:server", "dev:web"] - }, - "dev:server": "cd apps/server && deno task dev", - "dev:web": "cd apps/web && deno task dev", - "db:up": "docker compose -f docker-compose.dev.yml up -d --wait", - "dev:up": { - "description": "开发环境就绪:起开发库(55433)并迁移到最新;之后 deno task dev(API 4390、web 5180)", - "command": "deno task db:up && deno task db:migrate" - }, - "db:migrate": "deno run -A --env-file=.env packages/db/scripts/migrate.ts latest", - "db:reset": "deno run -A --env-file=.env packages/db/scripts/migrate.ts reset", - "gen:types": "deno run -A --env-file=.env packages/db/scripts/gen-types.ts", - "types": "mkdir -p types && deno types > types/deno.d.ts", - "typecheck": "deno task types && deno task typecheck:tsc && deno task typecheck:deno", - "typecheck:tsc": "deno task --members -j 1 typecheck", - "typecheck:deno": "deno check apps/server/src/main.ts packages/contracts/src/index.ts packages/db/src/index.ts packages/db/scripts/migrate.ts packages/db/scripts/gen-types.ts packages/db/migrations/*.ts packages/domain/src/index.ts packages/mapping/src/index.ts packages/tasks/src/index.ts packages/agent/src/index.ts packages/adapters/src/index.ts packages/testkit/src/index.ts", - "lint": "deno run -A npm:@biomejs/biome check .", - "fmt": "deno run -A npm:@biomejs/biome check --write .", - "test": "deno task --members -j 1 test", - "test:all": { - "description": "全量测试:逐包跑完再汇总(不在首个失败处停),连不上测试库直接失败;web 用 Node 跑", - "command": "bash scripts/test-all.sh" - }, - "test:web:smoke": { - "description": "前端全页面冒烟(Playwright):需要已启动的后端 + web 与已初始化的管理员,见 README", - "command": "deno run -A apps/web/scripts/smoke.mjs" - }, - "check:tokens": "cd apps/web && deno task check:tokens", - "check:copy": { - "description": "文案标点检查(规范见 docs/design/glossary.md「标点」);--fix 自动修正,--strict 有违规即失败", - "command": "deno run -A scripts/check-copy-punctuation.mjs --strict" - }, - "build": "cd apps/web && deno task build && cd ../server && deno task compile", - "check:binary": { - "description": "编译产物自检:空环境跑 dist/umt-server --role preflight,确认完整模块图可加载(防 deno compile 漏嵌 npm 模块)", - "command": "sh scripts/check-compiled-binary.sh" - }, - "ci": "deno task typecheck && deno task lint && deno task test:all && deno task check:tokens && deno task check:copy && deno task build && deno task check:binary", - "gen:tokens": "cd apps/web && deno task gen:tokens", - "release": { - "description": "测试环境发布:要求工作区干净与 CHANGELOG 条目,打 tag、构建镜像、只替换 app、核对健康版本(scripts/release.sh)", - "command": "bash scripts/release.sh" - } - } -} diff --git a/deno.lock b/deno.lock deleted file mode 100644 index 7b7f4429..00000000 --- a/deno.lock +++ /dev/null @@ -1,4720 +0,0 @@ -{ - "version": "5", - "specifiers": { - "jsr:@std/internal@^1.0.14": "1.0.14", - "jsr:@std/path@1": "1.1.6", - "npm:7z-wasm@1.2.0": "1.2.0", - "npm:@babel/parser@7.29.9": "7.29.9", - "npm:@biomejs/biome@*": "2.5.15", - "npm:@biomejs/biome@2.5.15": "2.5.15", - "npm:@earendil-works/chord@1.1.0": "1.1.0", - "npm:@earendil-works/pi-ai@1.1.0": "1.1.0_@modelcontextprotocol+sdk@1.31.0_zod@4.6.5", - "npm:@earendil-works/pi-durable@1.1.0": "1.1.0_@modelcontextprotocol+sdk@1.31.0_zod@4.6.5", - "npm:@fontsource-variable/inter-tight@^5.3.0": "5.3.0", - "npm:@fontsource-variable/space-grotesk@^5.3.0": "5.3.0", - "npm:@hono/swagger-ui@0.6.1": "0.6.1_hono@4.13.12", - "npm:@hono/zod-openapi@1.6.3": "1.6.3_hono@4.13.12_zod@4.6.5", - "npm:@modelcontextprotocol/sdk@1.31.0": "1.31.0_zod@4.6.5", - "npm:@react-router/dev@8.4.0": "8.4.0_react-router@8.4.0_typescript@7.0.2_vite@8.3.1_@types+node@24.19.0_react@19.3.0_react-dom@19.3.0_yaml@2.9.1", - "npm:@react-router/node@8.4.0": "8.4.0_react-router@8.4.0_typescript@7.0.2_react@19.3.0_react-dom@19.3.0", - "npm:@simplewebauthn/browser@14": "14.0.0", - "npm:@simplewebauthn/server@14.0.3": "14.0.3", - "npm:@tailwindcss/vite@^4.3.3": "4.3.3_vite@8.3.1_@types+node@24.19.0_yaml@2.9.1", - "npm:@tanstack/react-query@^5.104.0": "5.104.0_react@19.3.0", - "npm:@types/node@24.19.0": "24.19.0", - "npm:@types/nunjucks@3.2.6": "3.2.6", - "npm:@types/pg@8.23.1": "8.23.1", - "npm:@types/react-dom@^19.3.0": "19.3.0_@types+react@19.3.0", - "npm:@types/react@^19.3.0": "19.3.0", - "npm:@xmldom/xmldom@0.9.12": "0.9.12", - "npm:cheerio@1.2.0": "1.2.0", - "npm:class-variance-authority@~0.7.1": "0.7.1", - "npm:clsx@^2.1.1": "2.1.1", - "npm:date-fns@^4.4.0": "4.4.0", - "npm:fast-xml-parser@5.11.2": "5.11.2", - "npm:fflate@0.8.3": "0.8.3", - "npm:hash-wasm@4.12.0": "4.12.0", - "npm:hono@4.13.12": "4.13.12", - "npm:isbot@^5.2.2": "5.2.2", - "npm:jpeg-js@0.4.4": "0.4.4", - "npm:kysely@0.29.6": "0.29.6", - "npm:lucide-react@^1.49.0": "1.49.0_@types+react@19.3.0_react@19.3.0", - "npm:next-themes@~0.4.6": "0.4.6_react@19.3.0_react-dom@19.3.0", - "npm:node-unrar-js@2.0.2": "2.0.2", - "npm:nunjucks@3.2.4": "3.2.4", - "npm:openworkflow@0.10.1": "0.10.1_postgres@3.4.9", - "npm:pg@8.23.1": "8.23.1", - "npm:pino-pretty@13.1.3": "13.1.3", - "npm:pino@10.3.1": "10.3.1", - "npm:playwright-core@1.63.0": "1.63.0", - "npm:postgres@3.4.9": "3.4.9", - "npm:qr-code-styling@^1.9.2": "1.9.2", - "npm:radix-ui@^1.6.7": "1.6.7_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0", - "npm:react-day-picker@^10.0.2": "10.0.2_@types+react@19.3.0_react@19.3.0", - "npm:react-dom@^19.3.0": "19.3.0_react@19.3.0", - "npm:react-router@8.4.0": "8.4.0_react@19.3.0_react-dom@19.3.0", - "npm:react@^19.3.0": "19.3.0", - "npm:recharts@^3.10.1": "3.10.1_react@19.3.0_react-dom@19.3.0_react-is@19.3.0_@types+react@19.3.0_redux@5.0.1", - "npm:simple-liquid-glass@5.3.0": "5.3.0_react@19.3.0_react-dom@19.3.0", - "npm:sonner@^2.0.8": "2.0.8_@types+react@19.3.0_react@19.3.0_react-dom@19.3.0", - "npm:tailwind-merge@^3.7.0": "3.7.0", - "npm:tailwindcss@^4.3.3": "4.3.3", - "npm:tw-animate-css@^1.4.0": "1.4.0", - "npm:typebox@1.3.34": "1.3.34", - "npm:typescript@*": "7.0.2", - "npm:typescript@7.0.2": "7.0.2", - "npm:vite-tsconfig-paths@^6.1.1": "6.1.1_vite@8.3.1_@types+node@24.19.0_typescript@7.0.2_yaml@2.9.1", - "npm:vite@*": "8.3.1_@types+node@24.19.0_yaml@2.9.1", - "npm:vite@^8.3.1": "8.3.1_@types+node@24.19.0_yaml@2.9.1", - "npm:vitest@*": "5.0.3_@types+node@24.19.0_vite@8.3.1_yaml@2.9.1", - "npm:vitest@5.0.3": "5.0.3_@types+node@24.19.0_vite@8.3.1_yaml@2.9.1", - "npm:xpath@0.0.34": "0.0.34", - "npm:yaml@2.9.1": "2.9.1", - "npm:zod@4.6.5": "4.6.5", - "npm:zod@^4.6.5": "4.6.5" - }, - "jsr": { - "@std/internal@1.0.14": { - "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" - }, - "@std/path@1.1.6": { - "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", - "dependencies": [ - "jsr:@std/internal" - ] - } - }, - "npm": { - "7z-wasm@1.2.0": { - "integrity": "sha512-AZjtWXFleAe5nQ27JYISs70cICjO0zZr4LU45I9pXKcrhG0qiEWgq3Vjwr9JHHihFLuqgc7uSHYVn/z3vXCjdw==", - "dependencies": [ - "readline-sync" - ], - "bin": true - }, - "@anthropic-ai/sdk@0.129.0_zod@4.6.5": { - "integrity": "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==", - "dependencies": [ - "json-schema-to-ts", - "standardwebhooks", - "zod" - ], - "optionalPeers": [ - "zod" - ], - "bin": true - }, - "@asteasolutions/zod-to-openapi@9.1.0_zod@4.6.5": { - "integrity": "sha512-pLMeRgRYS7/vZIgAAkOe2P6+XGTifR1MT9pqDoxZ11EmcJJ+DPmdPqLN8ZZF4U8R9KHCCQCS9c2wtuE8wSrfkw==", - "dependencies": [ - "openapi3-ts", - "zod" - ] - }, - "@aws-sdk/client-bedrock-runtime@3.1127.0": { - "integrity": "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==", - "dependencies": [ - "@aws-sdk/core", - "@aws-sdk/credential-provider-node", - "@aws-sdk/eventstream-handler-node", - "@aws-sdk/middleware-eventstream", - "@aws-sdk/middleware-websocket", - "@aws-sdk/token-providers@3.1127.0", - "@aws-sdk/types", - "@smithy/core", - "@smithy/fetch-http-handler", - "@smithy/node-http-handler", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/core@3.978.1": { - "integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==", - "dependencies": [ - "@aws-sdk/types", - "@aws-sdk/xml-builder", - "@aws/lambda-invoke-store", - "@smithy/core", - "@smithy/signature-v4", - "@smithy/types", - "bowser", - "tslib@2.8.1" - ] - }, - "@aws-sdk/credential-provider-env@3.972.72": { - "integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==", - "dependencies": [ - "@aws-sdk/core", - "@aws-sdk/types", - "@smithy/core", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/credential-provider-http@3.972.74": { - "integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==", - "dependencies": [ - "@aws-sdk/core", - "@aws-sdk/types", - "@smithy/core", - "@smithy/fetch-http-handler", - "@smithy/node-http-handler", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/credential-provider-ini@3.973.17": { - "integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==", - "dependencies": [ - "@aws-sdk/core", - "@aws-sdk/credential-provider-env", - "@aws-sdk/credential-provider-http", - "@aws-sdk/credential-provider-login", - "@aws-sdk/credential-provider-process", - "@aws-sdk/credential-provider-sso", - "@aws-sdk/credential-provider-web-identity", - "@aws-sdk/nested-clients", - "@aws-sdk/types", - "@smithy/core", - "@smithy/credential-provider-imds", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/credential-provider-login@3.972.79": { - "integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==", - "dependencies": [ - "@aws-sdk/core", - "@aws-sdk/nested-clients", - "@aws-sdk/types", - "@smithy/core", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/credential-provider-node@3.972.84": { - "integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==", - "dependencies": [ - "@aws-sdk/credential-provider-env", - "@aws-sdk/credential-provider-http", - "@aws-sdk/credential-provider-ini", - "@aws-sdk/credential-provider-process", - "@aws-sdk/credential-provider-sso", - "@aws-sdk/credential-provider-web-identity", - "@aws-sdk/types", - "@smithy/core", - "@smithy/credential-provider-imds", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/credential-provider-process@3.972.72": { - "integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==", - "dependencies": [ - "@aws-sdk/core", - "@aws-sdk/types", - "@smithy/core", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/credential-provider-sso@3.973.16": { - "integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==", - "dependencies": [ - "@aws-sdk/core", - "@aws-sdk/nested-clients", - "@aws-sdk/token-providers@3.1138.0", - "@aws-sdk/types", - "@smithy/core", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/credential-provider-web-identity@3.972.78": { - "integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==", - "dependencies": [ - "@aws-sdk/core", - "@aws-sdk/nested-clients", - "@aws-sdk/types", - "@smithy/core", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/eventstream-handler-node@3.972.35": { - "integrity": "sha512-a8xilRoRaalvSPZdfrs0VY3/BPc0uYhXW56Z/k6nmZ5Vk430LHeowf9APIPq8utM3tMJSNGSSuJMlB9YrPsN+A==", - "dependencies": [ - "@aws-sdk/types", - "@smithy/core", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/middleware-eventstream@3.972.30": { - "integrity": "sha512-B6gvZlcnRBNWraKgyEjKsbhv+VjtbyHmpU7hmtTHzXpDSxHPpCJnYLpglEOOsF+SFne20DbZC5IVC1aNr2Pb4A==", - "dependencies": [ - "@aws-sdk/types", - "@smithy/core", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/middleware-websocket@3.972.54": { - "integrity": "sha512-bhESdru8u8KosziH8jcVta/NM/dNreeRz2+2aU86si5bfBYOiJ6IiqD+9U4URUcU5WJO3Yw+jO3GLDIHlscXMQ==", - "dependencies": [ - "@aws-sdk/core", - "@aws-sdk/types", - "@smithy/core", - "@smithy/fetch-http-handler", - "@smithy/signature-v4", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/nested-clients@3.997.46": { - "integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==", - "dependencies": [ - "@aws-sdk/core", - "@aws-sdk/signature-v4-multi-region", - "@aws-sdk/types", - "@smithy/core", - "@smithy/fetch-http-handler", - "@smithy/node-http-handler", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/signature-v4-multi-region@3.996.47": { - "integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==", - "dependencies": [ - "@aws-sdk/types", - "@smithy/signature-v4", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/token-providers@3.1127.0": { - "integrity": "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==", - "dependencies": [ - "@aws-sdk/core", - "@aws-sdk/nested-clients", - "@aws-sdk/types", - "@smithy/core", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/token-providers@3.1138.0": { - "integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==", - "dependencies": [ - "@aws-sdk/core", - "@aws-sdk/nested-clients", - "@aws-sdk/types", - "@smithy/core", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/types@3.974.6": { - "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", - "dependencies": [ - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws-sdk/xml-builder@3.972.41": { - "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", - "dependencies": [ - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@aws/lambda-invoke-store@0.3.0": { - "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==" - }, - "@babel/code-frame@7.29.7": { - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dependencies": [ - "@babel/helper-validator-identifier", - "js-tokens", - "picocolors" - ] - }, - "@babel/compat-data@7.29.7": { - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==" - }, - "@babel/core@7.29.7": { - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dependencies": [ - "@babel/code-frame", - "@babel/generator", - "@babel/helper-compilation-targets", - "@babel/helper-module-transforms", - "@babel/helpers", - "@babel/parser", - "@babel/template", - "@babel/traverse", - "@babel/types", - "@jridgewell/remapping", - "convert-source-map", - "debug", - "gensync", - "json5", - "semver@6.3.1" - ] - }, - "@babel/generator@7.29.8": { - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", - "dependencies": [ - "@babel/parser", - "@babel/types", - "@jridgewell/gen-mapping", - "@jridgewell/trace-mapping", - "jsesc" - ] - }, - "@babel/helper-compilation-targets@7.29.7": { - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dependencies": [ - "@babel/compat-data", - "@babel/helper-validator-option", - "browserslist", - "lru-cache", - "semver@6.3.1" - ] - }, - "@babel/helper-globals@7.29.7": { - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==" - }, - "@babel/helper-module-imports@7.29.7": { - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dependencies": [ - "@babel/traverse", - "@babel/types" - ] - }, - "@babel/helper-module-transforms@7.29.7_@babel+core@7.29.7": { - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dependencies": [ - "@babel/core", - "@babel/helper-module-imports", - "@babel/helper-validator-identifier", - "@babel/traverse" - ] - }, - "@babel/helper-string-parser@7.29.7": { - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==" - }, - "@babel/helper-validator-identifier@7.29.7": { - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==" - }, - "@babel/helper-validator-option@7.29.7": { - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==" - }, - "@babel/helpers@7.29.7": { - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dependencies": [ - "@babel/template", - "@babel/types" - ] - }, - "@babel/parser@7.29.9": { - "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", - "dependencies": [ - "@babel/types" - ], - "bin": true - }, - "@babel/runtime@7.29.10": { - "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==" - }, - "@babel/template@7.29.7": { - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dependencies": [ - "@babel/code-frame", - "@babel/parser", - "@babel/types" - ] - }, - "@babel/traverse@7.29.8": { - "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", - "dependencies": [ - "@babel/code-frame", - "@babel/generator", - "@babel/helper-globals", - "@babel/parser", - "@babel/template", - "@babel/types", - "debug" - ] - }, - "@babel/types@7.29.8": { - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "dependencies": [ - "@babel/helper-string-parser", - "@babel/helper-validator-identifier" - ] - }, - "@biomejs/biome@2.5.15": { - "integrity": "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==", - "optionalDependencies": [ - "@biomejs/cli-darwin-arm64", - "@biomejs/cli-darwin-x64", - "@biomejs/cli-linux-arm64", - "@biomejs/cli-linux-arm64-musl", - "@biomejs/cli-linux-x64", - "@biomejs/cli-linux-x64-musl", - "@biomejs/cli-win32-arm64", - "@biomejs/cli-win32-x64" - ], - "bin": true - }, - "@biomejs/cli-darwin-arm64@2.5.15": { - "integrity": "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "@biomejs/cli-darwin-x64@2.5.15": { - "integrity": "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "@biomejs/cli-linux-arm64-musl@2.5.15": { - "integrity": "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@biomejs/cli-linux-arm64@2.5.15": { - "integrity": "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@biomejs/cli-linux-x64-musl@2.5.15": { - "integrity": "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@biomejs/cli-linux-x64@2.5.15": { - "integrity": "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@biomejs/cli-win32-arm64@2.5.15": { - "integrity": "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "@biomejs/cli-win32-x64@2.5.15": { - "integrity": "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==", - "os": ["win32"], - "cpu": ["x64"] - }, - "@date-fns/tz@1.5.0": { - "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==" - }, - "@earendil-works/chord@1.1.0": { - "integrity": "sha512-gsHzKfyQ3t0ZIQ3cLBjJ5Vqy1TYnFyruHIV3g3BwmulCDYe5BW4yT9vaJk9QzN6YANILIO75uzuq+Gdxtzz1og==", - "dependencies": [ - "esbuild" - ] - }, - "@earendil-works/pi-ai@1.1.0_@modelcontextprotocol+sdk@1.31.0_zod@4.6.5": { - "integrity": "sha512-1T7LAkc/5Bvc0v6w4vAGVdCrli0o/E0pEmYKTnixu95vSFArBjvbhS/G4ZwI0RUePgf0Imcu0VyqlM4EcXxqfw==", - "dependencies": [ - "@anthropic-ai/sdk", - "@aws-sdk/client-bedrock-runtime", - "@earendil-works/pi-telemetry", - "@google/genai", - "@smithy/node-http-handler", - "http-proxy-agent", - "https-proxy-agent@9.1.0", - "openai", - "partial-json", - "typebox@1.3.27" - ], - "bin": true - }, - "@earendil-works/pi-durable@1.1.0_@modelcontextprotocol+sdk@1.31.0_zod@4.6.5": { - "integrity": "sha512-NtuB3RY24vQ+0/rWYpeHupyFWBfXuKq17to3VMYUYqfD/uuGnz6F6UTPBEm51VSxxA6LZwRcDwLX2LtRehnIgQ==", - "dependencies": [ - "@earendil-works/chord", - "@earendil-works/pi-ai", - "diff", - "typebox@1.3.27" - ] - }, - "@earendil-works/pi-telemetry@1.1.0": { - "integrity": "sha512-8gAK05/2pPozZZz6hCLOi8x2vsqzvsmO9gG92kbvuvTPm0qMHaFlaXX98ndXsrOi5pmzha7vNLguuG4+0Rgxjw==" - }, - "@esbuild/aix-ppc64@0.28.2": { - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "os": ["aix"], - "cpu": ["ppc64"] - }, - "@esbuild/android-arm64@0.28.2": { - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "os": ["android"], - "cpu": ["arm64"] - }, - "@esbuild/android-arm@0.28.2": { - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "os": ["android"], - "cpu": ["arm"] - }, - "@esbuild/android-x64@0.28.2": { - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "os": ["android"], - "cpu": ["x64"] - }, - "@esbuild/darwin-arm64@0.28.2": { - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "@esbuild/darwin-x64@0.28.2": { - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "@esbuild/freebsd-arm64@0.28.2": { - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "os": ["freebsd"], - "cpu": ["arm64"] - }, - "@esbuild/freebsd-x64@0.28.2": { - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "@esbuild/linux-arm64@0.28.2": { - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@esbuild/linux-arm@0.28.2": { - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "os": ["linux"], - "cpu": ["arm"] - }, - "@esbuild/linux-ia32@0.28.2": { - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "os": ["linux"], - "cpu": ["ia32"] - }, - "@esbuild/linux-loong64@0.28.2": { - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "os": ["linux"], - "cpu": ["loong64"] - }, - "@esbuild/linux-mips64el@0.28.2": { - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "os": ["linux"], - "cpu": ["mips64el"] - }, - "@esbuild/linux-ppc64@0.28.2": { - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "os": ["linux"], - "cpu": ["ppc64"] - }, - "@esbuild/linux-riscv64@0.28.2": { - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "os": ["linux"], - "cpu": ["riscv64"] - }, - "@esbuild/linux-s390x@0.28.2": { - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "os": ["linux"], - "cpu": ["s390x"] - }, - "@esbuild/linux-x64@0.28.2": { - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@esbuild/netbsd-arm64@0.28.2": { - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "os": ["netbsd"], - "cpu": ["arm64"] - }, - "@esbuild/netbsd-x64@0.28.2": { - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "os": ["netbsd"], - "cpu": ["x64"] - }, - "@esbuild/openbsd-arm64@0.28.2": { - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "os": ["openbsd"], - "cpu": ["arm64"] - }, - "@esbuild/openbsd-x64@0.28.2": { - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "os": ["openbsd"], - "cpu": ["x64"] - }, - "@esbuild/openharmony-arm64@0.28.2": { - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "os": ["openharmony"], - "cpu": ["arm64"] - }, - "@esbuild/sunos-x64@0.28.2": { - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "os": ["sunos"], - "cpu": ["x64"] - }, - "@esbuild/win32-arm64@0.28.2": { - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "@esbuild/win32-ia32@0.28.2": { - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "os": ["win32"], - "cpu": ["ia32"] - }, - "@esbuild/win32-x64@0.28.2": { - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "os": ["win32"], - "cpu": ["x64"] - }, - "@floating-ui/core@1.8.0": { - "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", - "dependencies": [ - "@floating-ui/utils" - ] - }, - "@floating-ui/dom@1.8.0": { - "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", - "dependencies": [ - "@floating-ui/core", - "@floating-ui/utils" - ] - }, - "@floating-ui/react-dom@2.1.9_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", - "dependencies": [ - "@floating-ui/dom", - "react", - "react-dom" - ] - }, - "@floating-ui/utils@0.2.12": { - "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==" - }, - "@fontsource-variable/inter-tight@5.3.0": { - "integrity": "sha512-ZhZZ29sNZ15P1K+cUt13GeBRYON0akgmNiftshUeDLos7X7zGcRgPzt434879Xhq1QbwlW3M/apBMk7jWabwvA==" - }, - "@fontsource-variable/space-grotesk@5.3.0": { - "integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==" - }, - "@google/genai@2.21.0_@modelcontextprotocol+sdk@1.31.0": { - "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", - "dependencies": [ - "@modelcontextprotocol/sdk", - "google-auth-library", - "p-retry", - "protobufjs", - "ws" - ], - "optionalPeers": [ - "@modelcontextprotocol/sdk" - ], - "scripts": true - }, - "@hexagon/base64@1.1.28": { - "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==" - }, - "@hono/node-server@2.1.3_hono@4.13.12": { - "integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==", - "dependencies": [ - "hono" - ] - }, - "@hono/swagger-ui@0.6.1_hono@4.13.12": { - "integrity": "sha512-sJTvldu1GPeEPfyeLG7gRj+W4vEuD+JDi+JjJ3TJs/DvMUtBLs0KJO5yokGegWWdy5qrbdnQGekbhgNRmPmYKQ==", - "dependencies": [ - "hono" - ] - }, - "@hono/zod-openapi@1.6.3_hono@4.13.12_zod@4.6.5": { - "integrity": "sha512-VYR82Y60plu8g3pN60OUXMVKpmxh3R04Qs++3DY9vtwZ9/sA/BRY0PcRIR+Z37/3itWtM7pYwT9bKiH9JhqV6Q==", - "dependencies": [ - "@asteasolutions/zod-to-openapi", - "@hono/zod-validator", - "hono", - "openapi3-ts", - "zod" - ] - }, - "@hono/zod-validator@0.9.1_hono@4.13.12_zod@4.6.5": { - "integrity": "sha512-iiv6w0qrIc0arfvCtUqBWsvl4fXjzaTcQcCJTTtCnkawF9HHGE+KjEl0ox3gQJ6rKZEgE3mLExlQCF72M+mNuw==", - "dependencies": [ - "hono", - "zod" - ] - }, - "@jridgewell/gen-mapping@0.3.13": { - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dependencies": [ - "@jridgewell/sourcemap-codec", - "@jridgewell/trace-mapping" - ] - }, - "@jridgewell/remapping@2.3.5": { - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dependencies": [ - "@jridgewell/gen-mapping", - "@jridgewell/trace-mapping" - ] - }, - "@jridgewell/resolve-uri@3.1.2": { - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" - }, - "@jridgewell/sourcemap-codec@1.6.0": { - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==" - }, - "@jridgewell/trace-mapping@0.3.31": { - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dependencies": [ - "@jridgewell/resolve-uri", - "@jridgewell/sourcemap-codec" - ] - }, - "@levischuck/tiny-cbor@0.2.11": { - "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==" - }, - "@modelcontextprotocol/sdk@1.31.0_zod@4.6.5": { - "integrity": "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==", - "dependencies": [ - "@hono/node-server", - "ajv", - "ajv-formats", - "content-type@1.0.5", - "cors", - "cross-spawn", - "eventsource", - "eventsource-parser", - "express", - "express-rate-limit", - "hono", - "jose", - "json-schema-typed", - "pkce-challenge", - "raw-body", - "zod", - "zod-to-json-schema" - ] - }, - "@nodable/entities@3.1.0": { - "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==" - }, - "@oxc-project/types@0.152.0": { - "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==" - }, - "@peculiar/asn1-android@2.10.0": { - "integrity": "sha512-IHKdYL5MRW76DOQCv2HSE/vUMOf2jqH4E7yhtQYe/xx+RxpPiXfkUdhex0N4/s5crsnXCGftRNzy4RJn1s6CZA==", - "dependencies": [ - "@peculiar/asn1-schema", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-asym-key@2.10.0": { - "integrity": "sha512-1R8xUTeqcUTSMLFTTCzJyFuKiq3y25EQKs9DezUetJ8qMqbJyxo3XkzykiwwT/Xzlt5/vRzS/sW4M1r+pq1FTg==", - "dependencies": [ - "@peculiar/asn1-pkcs8", - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-cms@2.10.0": { - "integrity": "sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "@peculiar/asn1-x509-attr", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-csr@2.10.0": { - "integrity": "sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-ecc@2.10.0": { - "integrity": "sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-pfx@2.10.0": { - "integrity": "sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA==", - "dependencies": [ - "@peculiar/asn1-cms", - "@peculiar/asn1-pkcs8", - "@peculiar/asn1-rsa", - "@peculiar/asn1-schema", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-pkcs8@2.10.0": { - "integrity": "sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-pkcs9@2.10.0": { - "integrity": "sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ==", - "dependencies": [ - "@peculiar/asn1-cms", - "@peculiar/asn1-pfx", - "@peculiar/asn1-pkcs8", - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "@peculiar/asn1-x509-attr", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-rsa@2.10.0": { - "integrity": "sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-schema@2.10.0": { - "integrity": "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==", - "dependencies": [ - "@peculiar/utils", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-x509-attr@2.10.0": { - "integrity": "sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-x509-post-quantum@2.10.0": { - "integrity": "sha512-cXKcZ30wKbUkRK5RK4Z3W1imewXvHAB9apEAuIqJ0OOkOQCpJrwLOuN38QlsEXqOI+3gtUv8Kf3UZohUmCbf3w==", - "dependencies": [ - "@peculiar/asn1-asym-key", - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-x509@2.10.0": { - "integrity": "sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/utils", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/utils@2.0.3": { - "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "dependencies": [ - "tslib@2.8.1" - ] - }, - "@peculiar/x509@2.1.0": { - "integrity": "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==", - "dependencies": [ - "@peculiar/asn1-cms", - "@peculiar/asn1-csr", - "@peculiar/asn1-ecc", - "@peculiar/asn1-pkcs9", - "@peculiar/asn1-rsa", - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "@peculiar/asn1-x509-post-quantum", - "pvtsutils", - "tslib@2.8.1", - "tsyringe" - ] - }, - "@pinojs/redact@0.4.0": { - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" - }, - "@protobufjs/aspromise@1.1.2": { - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" - }, - "@protobufjs/base64@1.1.2": { - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" - }, - "@protobufjs/codegen@2.0.5": { - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==" - }, - "@protobufjs/eventemitter@1.1.1": { - "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==" - }, - "@protobufjs/fetch@1.1.1": { - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "dependencies": [ - "@protobufjs/aspromise" - ] - }, - "@protobufjs/float@1.0.2": { - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" - }, - "@protobufjs/path@1.1.2": { - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" - }, - "@protobufjs/pool@1.1.0": { - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" - }, - "@protobufjs/utf8@1.1.2": { - "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==" - }, - "@radix-ui/number@1.1.3": { - "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==" - }, - "@radix-ui/primitive@1.1.7": { - "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==" - }, - "@radix-ui/react-accessible-icon@1.1.15_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==", - "dependencies": [ - "@radix-ui/react-visually-hidden", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-accordion@1.2.20_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-collapsible", - "@radix-ui/react-collection", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-direction", - "@radix-ui/react-id", - "@radix-ui/react-primitive", - "@radix-ui/react-use-controllable-state", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-alert-dialog@1.1.23_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-dialog", - "@radix-ui/react-primitive", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-arrow@1.1.15_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", - "dependencies": [ - "@radix-ui/react-primitive", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-aspect-ratio@1.1.15_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==", - "dependencies": [ - "@radix-ui/react-primitive", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-avatar@1.2.6_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-context", - "@radix-ui/react-primitive", - "@radix-ui/react-use-callback-ref", - "@radix-ui/react-use-is-hydrated", - "@radix-ui/react-use-layout-effect", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-checkbox@1.3.11_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-use-controllable-state", - "@radix-ui/react-use-size", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-collapsible@1.1.20_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-id", - "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-use-controllable-state", - "@radix-ui/react-use-layout-effect", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-collection@1.1.15_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", - "dependencies": [ - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-primitive", - "@radix-ui/react-slot", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-compose-refs@1.1.5_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", - "dependencies": [ - "@types/react", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "@radix-ui/react-context-menu@2.3.7_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-context", - "@radix-ui/react-menu", - "@radix-ui/react-primitive", - "@radix-ui/react-use-controllable-state", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-context@1.2.2_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", - "dependencies": [ - "@types/react", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "@radix-ui/react-dialog@1.1.23_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-dismissable-layer", - "@radix-ui/react-focus-guards", - "@radix-ui/react-focus-scope", - "@radix-ui/react-id", - "@radix-ui/react-portal", - "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-slot", - "@radix-ui/react-use-controllable-state", - "@radix-ui/react-use-layout-effect", - "@types/react", - "@types/react-dom", - "aria-hidden", - "react", - "react-dom", - "react-remove-scroll" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-direction@1.1.4_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", - "dependencies": [ - "@types/react", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "@radix-ui/react-dismissable-layer@1.1.19_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-compose-refs", - "@radix-ui/react-primitive", - "@radix-ui/react-use-callback-ref", - "@radix-ui/react-use-effect-event", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-dropdown-menu@2.1.24_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-id", - "@radix-ui/react-menu", - "@radix-ui/react-primitive", - "@radix-ui/react-use-controllable-state", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-focus-guards@1.1.6_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", - "dependencies": [ - "@types/react", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "@radix-ui/react-focus-scope@1.1.16_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", - "dependencies": [ - "@radix-ui/react-compose-refs", - "@radix-ui/react-primitive", - "@radix-ui/react-use-callback-ref", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-form@0.1.16_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-id", - "@radix-ui/react-label", - "@radix-ui/react-primitive", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-hover-card@1.1.23_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-dismissable-layer", - "@radix-ui/react-popper", - "@radix-ui/react-portal", - "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-use-controllable-state", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-id@1.1.4_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", - "dependencies": [ - "@radix-ui/react-use-layout-effect", - "@types/react", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "@radix-ui/react-label@2.1.15_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", - "dependencies": [ - "@radix-ui/react-primitive", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-menu@2.1.24_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-collection", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-direction", - "@radix-ui/react-dismissable-layer", - "@radix-ui/react-focus-guards", - "@radix-ui/react-focus-scope", - "@radix-ui/react-id", - "@radix-ui/react-popper", - "@radix-ui/react-portal", - "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-roving-focus", - "@radix-ui/react-slot", - "@radix-ui/react-use-callback-ref", - "@types/react", - "@types/react-dom", - "aria-hidden", - "react", - "react-dom", - "react-remove-scroll" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-menubar@1.1.24_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-collection", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-direction", - "@radix-ui/react-id", - "@radix-ui/react-menu", - "@radix-ui/react-primitive", - "@radix-ui/react-roving-focus", - "@radix-ui/react-use-controllable-state", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-navigation-menu@1.2.22_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-collection", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-direction", - "@radix-ui/react-dismissable-layer", - "@radix-ui/react-id", - "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-use-callback-ref", - "@radix-ui/react-use-controllable-state", - "@radix-ui/react-use-layout-effect", - "@radix-ui/react-use-previous", - "@radix-ui/react-visually-hidden", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-one-time-password-field@0.1.16_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw==", - "dependencies": [ - "@radix-ui/number", - "@radix-ui/primitive", - "@radix-ui/react-collection", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-direction", - "@radix-ui/react-primitive", - "@radix-ui/react-roving-focus", - "@radix-ui/react-use-controllable-state", - "@radix-ui/react-use-effect-event", - "@radix-ui/react-use-is-hydrated", - "@radix-ui/react-use-layout-effect", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-password-toggle-field@0.1.11_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-id", - "@radix-ui/react-primitive", - "@radix-ui/react-use-controllable-state", - "@radix-ui/react-use-effect-event", - "@radix-ui/react-use-is-hydrated", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-popover@1.1.23_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-dismissable-layer", - "@radix-ui/react-focus-guards", - "@radix-ui/react-focus-scope", - "@radix-ui/react-id", - "@radix-ui/react-popper", - "@radix-ui/react-portal", - "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-slot", - "@radix-ui/react-use-controllable-state", - "@types/react", - "@types/react-dom", - "aria-hidden", - "react", - "react-dom", - "react-remove-scroll" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-popper@1.3.7_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", - "dependencies": [ - "@floating-ui/react-dom", - "@radix-ui/react-arrow", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-primitive", - "@radix-ui/react-use-callback-ref", - "@radix-ui/react-use-layout-effect", - "@radix-ui/react-use-rect", - "@radix-ui/react-use-size", - "@radix-ui/rect", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-portal@1.1.17_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", - "dependencies": [ - "@radix-ui/react-primitive", - "@radix-ui/react-use-layout-effect", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-presence@1.1.10_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", - "dependencies": [ - "@radix-ui/react-use-layout-effect", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-primitive@2.1.10_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", - "dependencies": [ - "@radix-ui/react-slot", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-progress@1.1.16_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==", - "dependencies": [ - "@radix-ui/react-context", - "@radix-ui/react-primitive", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-radio-group@1.4.7_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-direction", - "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-roving-focus", - "@radix-ui/react-use-controllable-state", - "@radix-ui/react-use-size", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-roving-focus@1.1.19_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-collection", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-direction", - "@radix-ui/react-id", - "@radix-ui/react-primitive", - "@radix-ui/react-use-callback-ref", - "@radix-ui/react-use-controllable-state", - "@radix-ui/react-use-is-hydrated", - "@radix-ui/react-use-layout-effect", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-scroll-area@1.2.18_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", - "dependencies": [ - "@radix-ui/number", - "@radix-ui/primitive", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-direction", - "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-use-callback-ref", - "@radix-ui/react-use-layout-effect", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-select@2.3.7_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", - "dependencies": [ - "@radix-ui/number", - "@radix-ui/primitive", - "@radix-ui/react-collection", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-direction", - "@radix-ui/react-dismissable-layer", - "@radix-ui/react-focus-guards", - "@radix-ui/react-focus-scope", - "@radix-ui/react-id", - "@radix-ui/react-popper", - "@radix-ui/react-portal", - "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-slot", - "@radix-ui/react-use-callback-ref", - "@radix-ui/react-use-controllable-state", - "@radix-ui/react-use-layout-effect", - "@radix-ui/react-use-previous", - "@radix-ui/react-visually-hidden", - "@types/react", - "@types/react-dom", - "aria-hidden", - "react", - "react-dom", - "react-remove-scroll" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-separator@1.1.15_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", - "dependencies": [ - "@radix-ui/react-primitive", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-slider@1.4.7_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==", - "dependencies": [ - "@radix-ui/number", - "@radix-ui/primitive", - "@radix-ui/react-collection", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-direction", - "@radix-ui/react-primitive", - "@radix-ui/react-use-controllable-state", - "@radix-ui/react-use-layout-effect", - "@radix-ui/react-use-previous", - "@radix-ui/react-use-size", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-slot@1.3.3_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", - "dependencies": [ - "@radix-ui/react-compose-refs", - "@types/react", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "@radix-ui/react-switch@1.3.7_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-primitive", - "@radix-ui/react-use-controllable-state", - "@radix-ui/react-use-size", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-tabs@1.1.21_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-context", - "@radix-ui/react-direction", - "@radix-ui/react-id", - "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-roving-focus", - "@radix-ui/react-use-controllable-state", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-toast@1.2.23_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-collection", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-dismissable-layer", - "@radix-ui/react-portal", - "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-use-callback-ref", - "@radix-ui/react-use-controllable-state", - "@radix-ui/react-use-layout-effect", - "@radix-ui/react-visually-hidden", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-toggle-group@1.1.19_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-context", - "@radix-ui/react-direction", - "@radix-ui/react-primitive", - "@radix-ui/react-roving-focus", - "@radix-ui/react-toggle", - "@radix-ui/react-use-controllable-state", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-toggle@1.1.18_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-primitive", - "@radix-ui/react-use-controllable-state", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-toolbar@1.1.19_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-context", - "@radix-ui/react-direction", - "@radix-ui/react-primitive", - "@radix-ui/react-roving-focus", - "@radix-ui/react-separator", - "@radix-ui/react-toggle-group", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-tooltip@1.2.16_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-dismissable-layer", - "@radix-ui/react-id", - "@radix-ui/react-popper", - "@radix-ui/react-portal", - "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-slot", - "@radix-ui/react-use-controllable-state", - "@radix-ui/react-use-layout-effect", - "@radix-ui/react-visually-hidden", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/react-use-callback-ref@1.1.4_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", - "dependencies": [ - "@types/react", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "@radix-ui/react-use-controllable-state@1.2.6_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-use-effect-event", - "@radix-ui/react-use-layout-effect", - "@types/react", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "@radix-ui/react-use-effect-event@0.0.5_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", - "dependencies": [ - "@radix-ui/react-use-layout-effect", - "@types/react", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "@radix-ui/react-use-escape-keydown@1.1.5_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==", - "dependencies": [ - "@radix-ui/react-use-callback-ref", - "@types/react", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "@radix-ui/react-use-is-hydrated@0.1.3_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", - "dependencies": [ - "@types/react", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "@radix-ui/react-use-layout-effect@1.1.4_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", - "dependencies": [ - "@types/react", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "@radix-ui/react-use-previous@1.1.4_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", - "dependencies": [ - "@types/react", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "@radix-ui/react-use-rect@1.1.4_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", - "dependencies": [ - "@radix-ui/rect", - "@types/react", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "@radix-ui/react-use-size@1.1.4_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", - "dependencies": [ - "@radix-ui/react-use-layout-effect", - "@types/react", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "@radix-ui/react-visually-hidden@1.2.11_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", - "dependencies": [ - "@radix-ui/react-primitive", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "@radix-ui/rect@1.1.3": { - "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==" - }, - "@react-router/dev@8.4.0_react-router@8.4.0_typescript@7.0.2_vite@8.3.1_@types+node@24.19.0_react@19.3.0_react-dom@19.3.0_yaml@2.9.1": { - "integrity": "sha512-GKznpn6h8c52xGXwtsypD5ZwNiWFSeqoKPdyGq7SfG+T9rFjGO8F0i/R/yECfZqHXaNz1NVmpODuBamBy9FfMg==", - "dependencies": [ - "@babel/core", - "@babel/generator", - "@babel/parser", - "@babel/traverse", - "@babel/types", - "@react-router/node", - "@remix-run/node-fetch-server", - "babel-dead-code-elimination", - "chokidar", - "dedent", - "es-module-lexer", - "exit-hook", - "isbot", - "jsesc", - "lodash", - "p-map", - "pathe", - "picocolors", - "pkg-types", - "react-refresh", - "react-router", - "semver@7.8.5", - "tinyglobby", - "typescript", - "valibot", - "vite" - ], - "optionalPeers": [ - "typescript" - ], - "bin": true - }, - "@react-router/node@8.4.0_react-router@8.4.0_typescript@7.0.2_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-1teZHtk7XDm1pteQ2Plgtvtjjup5C5sIwFlOMYVIyoOH7X3bId3Qb//kUdbydX6NRIxcGK28UnfZlz4SrD/mkg==", - "dependencies": [ - "@remix-run/node-fetch-server", - "react-router", - "typescript" - ], - "optionalPeers": [ - "typescript" - ] - }, - "@reduxjs/toolkit@2.12.0_react@19.3.0_react-redux@9.3.0_@types+react@19.3.0": { - "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", - "dependencies": [ - "@standard-schema/spec", - "@standard-schema/utils", - "immer", - "react", - "react-redux", - "redux", - "redux-thunk", - "reselect" - ], - "optionalPeers": [ - "react", - "react-redux" - ] - }, - "@remix-run/node-fetch-server@0.14.2": { - "integrity": "sha512-1cpM/f5i1ka/9CUMBwZXZp7Bn92x8VQjzg+F4eEe88DlVdNHkAKrf/vvbZoYgdkfy2bWHtGC6LQGLmojEqOF4Q==" - }, - "@remix-run/route-pattern@0.22.1": { - "integrity": "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==" - }, - "@rolldown/binding-android-arm-eabi@1.2.12": { - "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", - "os": ["android"], - "cpu": ["arm"] - }, - "@rolldown/binding-android-arm64@1.2.12": { - "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", - "os": ["android"], - "cpu": ["arm64"] - }, - "@rolldown/binding-darwin-arm64@1.2.12": { - "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "@rolldown/binding-darwin-x64@1.2.12": { - "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "@rolldown/binding-freebsd-x64@1.2.12": { - "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "@rolldown/binding-linux-arm-gnueabihf@1.2.12": { - "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", - "os": ["linux"], - "cpu": ["arm"] - }, - "@rolldown/binding-linux-arm64-gnu@1.2.12": { - "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@rolldown/binding-linux-arm64-musl@1.2.12": { - "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@rolldown/binding-linux-ppc64-gnu@1.2.12": { - "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", - "os": ["linux"], - "cpu": ["ppc64"] - }, - "@rolldown/binding-linux-s390x-gnu@1.2.12": { - "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", - "os": ["linux"], - "cpu": ["s390x"] - }, - "@rolldown/binding-linux-x64-gnu@1.2.12": { - "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@rolldown/binding-linux-x64-musl@1.2.12": { - "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@rolldown/binding-openharmony-arm64@1.2.12": { - "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", - "os": ["openharmony"], - "cpu": ["arm64"] - }, - "@rolldown/binding-win32-arm64-msvc@1.2.12": { - "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "@rolldown/binding-win32-x64-msvc@1.2.12": { - "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", - "os": ["win32"], - "cpu": ["x64"] - }, - "@rolldown/pluginutils@1.0.1": { - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==" - }, - "@simplewebauthn/browser@14.0.0": { - "integrity": "sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==" - }, - "@simplewebauthn/server@14.0.3": { - "integrity": "sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q==", - "dependencies": [ - "@hexagon/base64", - "@levischuck/tiny-cbor", - "@peculiar/asn1-android", - "@peculiar/asn1-ecc", - "@peculiar/asn1-rsa", - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "@peculiar/asn1-x509-post-quantum", - "@peculiar/x509", - "reflect-metadata" - ] - }, - "@smithy/core@3.35.1": { - "integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==", - "dependencies": [ - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@smithy/credential-provider-imds@4.5.2": { - "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", - "dependencies": [ - "@smithy/core", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@smithy/fetch-http-handler@5.8.0": { - "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", - "dependencies": [ - "@smithy/core", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@smithy/node-http-handler@4.12.1": { - "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", - "dependencies": [ - "@smithy/core", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@smithy/signature-v4@5.7.4": { - "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", - "dependencies": [ - "@smithy/core", - "@smithy/types", - "tslib@2.8.1" - ] - }, - "@smithy/types@4.19.0": { - "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", - "dependencies": [ - "tslib@2.8.1" - ] - }, - "@stablelib/base64@1.0.1": { - "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==" - }, - "@standard-schema/spec@1.1.0": { - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" - }, - "@standard-schema/utils@0.3.0": { - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" - }, - "@tailwindcss/node@4.3.3": { - "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", - "dependencies": [ - "@jridgewell/remapping", - "enhanced-resolve", - "jiti", - "lightningcss@1.32.0", - "magic-string@0.30.21", - "source-map-js", - "tailwindcss" - ] - }, - "@tailwindcss/oxide-android-arm64@4.3.3": { - "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", - "os": ["android"], - "cpu": ["arm64"] - }, - "@tailwindcss/oxide-darwin-arm64@4.3.3": { - "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "@tailwindcss/oxide-darwin-x64@4.3.3": { - "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "@tailwindcss/oxide-freebsd-x64@4.3.3": { - "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3": { - "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", - "os": ["linux"], - "cpu": ["arm"] - }, - "@tailwindcss/oxide-linux-arm64-gnu@4.3.3": { - "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@tailwindcss/oxide-linux-arm64-musl@4.3.3": { - "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@tailwindcss/oxide-linux-x64-gnu@4.3.3": { - "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@tailwindcss/oxide-linux-x64-musl@4.3.3": { - "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@tailwindcss/oxide-wasm32-wasi@4.3.3": { - "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", - "cpu": ["wasm32"] - }, - "@tailwindcss/oxide-win32-arm64-msvc@4.3.3": { - "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "@tailwindcss/oxide-win32-x64-msvc@4.3.3": { - "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", - "os": ["win32"], - "cpu": ["x64"] - }, - "@tailwindcss/oxide@4.3.3": { - "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", - "optionalDependencies": [ - "@tailwindcss/oxide-android-arm64", - "@tailwindcss/oxide-darwin-arm64", - "@tailwindcss/oxide-darwin-x64", - "@tailwindcss/oxide-freebsd-x64", - "@tailwindcss/oxide-linux-arm-gnueabihf", - "@tailwindcss/oxide-linux-arm64-gnu", - "@tailwindcss/oxide-linux-arm64-musl", - "@tailwindcss/oxide-linux-x64-gnu", - "@tailwindcss/oxide-linux-x64-musl", - "@tailwindcss/oxide-wasm32-wasi", - "@tailwindcss/oxide-win32-arm64-msvc", - "@tailwindcss/oxide-win32-x64-msvc" - ] - }, - "@tailwindcss/vite@4.3.3_vite@8.3.1_@types+node@24.19.0_yaml@2.9.1": { - "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", - "dependencies": [ - "@tailwindcss/node", - "@tailwindcss/oxide", - "tailwindcss", - "vite" - ] - }, - "@tanstack/query-core@5.104.0": { - "integrity": "sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ==" - }, - "@tanstack/react-query@5.104.0_react@19.3.0": { - "integrity": "sha512-e1TZmDCQnWIfiDVryIHeA6Idj+Lfx1hORLOhXS/l5wcgvtVD4yYqbTDl378sGQKIi2cSgb0TEMC9cKK8GGoJEw==", - "dependencies": [ - "@tanstack/query-core", - "react" - ] - }, - "@types/chai@5.2.3": { - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dependencies": [ - "@types/deep-eql", - "assertion-error" - ] - }, - "@types/d3-array@3.2.2": { - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" - }, - "@types/d3-color@3.1.3": { - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" - }, - "@types/d3-ease@3.0.2": { - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" - }, - "@types/d3-interpolate@3.0.4": { - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "dependencies": [ - "@types/d3-color" - ] - }, - "@types/d3-path@3.1.1": { - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" - }, - "@types/d3-scale@4.0.9": { - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "dependencies": [ - "@types/d3-time" - ] - }, - "@types/d3-shape@3.2.0": { - "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", - "dependencies": [ - "@types/d3-path" - ] - }, - "@types/d3-time@3.0.4": { - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" - }, - "@types/d3-timer@3.0.2": { - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" - }, - "@types/deep-eql@4.0.2": { - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==" - }, - "@types/estree@1.0.9": { - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==" - }, - "@types/node@24.19.0": { - "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", - "dependencies": [ - "undici-types" - ] - }, - "@types/nunjucks@3.2.6": { - "integrity": "sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==" - }, - "@types/pg@8.23.1": { - "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", - "dependencies": [ - "@types/node", - "pg-protocol", - "pg-types" - ] - }, - "@types/react-dom@19.3.0_@types+react@19.3.0": { - "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", - "dependencies": [ - "@types/react" - ] - }, - "@types/react@19.3.0": { - "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dependencies": [ - "csstype" - ] - }, - "@types/retry@0.12.0": { - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" - }, - "@types/use-sync-external-store@0.0.6": { - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" - }, - "@typescript/typescript-aix-ppc64@7.0.2": { - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "os": ["aix"], - "cpu": ["ppc64"] - }, - "@typescript/typescript-darwin-arm64@7.0.2": { - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "@typescript/typescript-darwin-x64@7.0.2": { - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "@typescript/typescript-freebsd-arm64@7.0.2": { - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "os": ["freebsd"], - "cpu": ["arm64"] - }, - "@typescript/typescript-freebsd-x64@7.0.2": { - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "@typescript/typescript-linux-arm64@7.0.2": { - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@typescript/typescript-linux-arm@7.0.2": { - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "os": ["linux"], - "cpu": ["arm"] - }, - "@typescript/typescript-linux-loong64@7.0.2": { - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "os": ["linux"], - "cpu": ["loong64"] - }, - "@typescript/typescript-linux-mips64el@7.0.2": { - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "os": ["linux"], - "cpu": ["mips64el"] - }, - "@typescript/typescript-linux-ppc64@7.0.2": { - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "os": ["linux"], - "cpu": ["ppc64"] - }, - "@typescript/typescript-linux-riscv64@7.0.2": { - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "os": ["linux"], - "cpu": ["riscv64"] - }, - "@typescript/typescript-linux-s390x@7.0.2": { - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "os": ["linux"], - "cpu": ["s390x"] - }, - "@typescript/typescript-linux-x64@7.0.2": { - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@typescript/typescript-netbsd-arm64@7.0.2": { - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "os": ["netbsd"], - "cpu": ["arm64"] - }, - "@typescript/typescript-netbsd-x64@7.0.2": { - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "os": ["netbsd"], - "cpu": ["x64"] - }, - "@typescript/typescript-openbsd-arm64@7.0.2": { - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "os": ["openbsd"], - "cpu": ["arm64"] - }, - "@typescript/typescript-openbsd-x64@7.0.2": { - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "os": ["openbsd"], - "cpu": ["x64"] - }, - "@typescript/typescript-sunos-x64@7.0.2": { - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "os": ["sunos"], - "cpu": ["x64"] - }, - "@typescript/typescript-win32-arm64@7.0.2": { - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "@typescript/typescript-win32-x64@7.0.2": { - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "os": ["win32"], - "cpu": ["x64"] - }, - "@vitest/mocker@5.0.3_vite@8.3.1_@types+node@24.19.0_yaml@2.9.1": { - "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", - "dependencies": [ - "@jridgewell/trace-mapping", - "@vitest/spy", - "estree-walker", - "magic-string@1.4.2", - "vite" - ], - "optionalPeers": [ - "vite" - ] - }, - "@vitest/spy@5.0.3": { - "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==" - }, - "@xmldom/xmldom@0.9.12": { - "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==" - }, - "a-sync-waterfall@1.0.1": { - "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==" - }, - "accepts@2.0.0": { - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dependencies": [ - "mime-types", - "negotiator" - ] - }, - "agent-base@7.1.4": { - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" - }, - "agent-base@9.0.0": { - "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==" - }, - "ajv-formats@3.0.1_ajv@8.20.0": { - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dependencies": [ - "ajv" - ], - "optionalPeers": [ - "ajv" - ] - }, - "ajv@8.20.0": { - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dependencies": [ - "fast-deep-equal", - "fast-uri", - "json-schema-traverse", - "require-from-string" - ] - }, - "anynum@1.0.1": { - "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==" - }, - "aria-hidden@1.2.6": { - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "dependencies": [ - "tslib@2.8.1" - ] - }, - "asap@2.0.6": { - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" - }, - "asn1js@3.0.10": { - "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "dependencies": [ - "pvtsutils", - "pvutils", - "tslib@2.8.1" - ] - }, - "assertion-error@2.0.1": { - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==" - }, - "atomic-sleep@1.0.0": { - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==" - }, - "babel-dead-code-elimination@1.0.12": { - "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", - "dependencies": [ - "@babel/core", - "@babel/parser", - "@babel/traverse", - "@babel/types" - ] - }, - "base64-js@1.5.1": { - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" - }, - "baseline-browser-mapping@2.11.26": { - "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", - "bin": true - }, - "bignumber.js@9.3.1": { - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==" - }, - "body-parser@2.3.0": { - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "dependencies": [ - "bytes", - "content-type@2.1.0", - "debug", - "http-errors", - "iconv-lite@0.7.3", - "on-finished", - "qs", - "raw-body", - "type-is" - ] - }, - "boolbase@1.0.0": { - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" - }, - "bowser@2.14.1": { - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==" - }, - "browserslist@4.29.3": { - "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", - "dependencies": [ - "baseline-browser-mapping", - "caniuse-lite", - "electron-to-chromium", - "node-releases", - "update-browserslist-db" - ], - "bin": true - }, - "buffer-equal-constant-time@1.0.1": { - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" - }, - "bytes@3.1.2": { - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" - }, - "call-bind-apply-helpers@1.0.2": { - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dependencies": [ - "es-errors", - "function-bind" - ] - }, - "call-bound@1.0.4": { - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dependencies": [ - "call-bind-apply-helpers", - "get-intrinsic" - ] - }, - "caniuse-lite@1.0.30001814": { - "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==" - }, - "chai@6.3.0": { - "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==" - }, - "cheerio-select@2.1.0": { - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", - "dependencies": [ - "boolbase", - "css-select", - "css-what", - "domelementtype", - "domhandler", - "domutils" - ] - }, - "cheerio@1.2.0": { - "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", - "dependencies": [ - "cheerio-select", - "dom-serializer", - "domhandler", - "domutils", - "encoding-sniffer", - "htmlparser2", - "parse5", - "parse5-htmlparser2-tree-adapter", - "parse5-parser-stream", - "undici", - "whatwg-mimetype" - ] - }, - "chokidar@5.0.0": { - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dependencies": [ - "readdirp" - ] - }, - "class-variance-authority@0.7.1": { - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "dependencies": [ - "clsx" - ] - }, - "clsx@2.1.1": { - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" - }, - "colorette@2.0.20": { - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==" - }, - "commander@5.1.0": { - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==" - }, - "confbox@0.3.1": { - "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==" - }, - "content-disposition@1.1.0": { - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==" - }, - "content-type@1.0.5": { - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" - }, - "content-type@2.1.0": { - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==" - }, - "convert-source-map@2.0.0": { - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" - }, - "cookie-es@3.1.1": { - "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==" - }, - "cookie-signature@1.2.2": { - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" - }, - "cookie@0.7.2": { - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" - }, - "cors@2.8.6": { - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "dependencies": [ - "object-assign", - "vary" - ] - }, - "cross-spawn@7.0.6": { - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dependencies": [ - "path-key", - "shebang-command", - "which" - ] - }, - "css-select@5.2.2": { - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", - "dependencies": [ - "boolbase", - "css-what", - "domhandler", - "domutils", - "nth-check" - ] - }, - "css-what@6.2.2": { - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==" - }, - "csstype@3.2.3": { - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" - }, - "d3-array@3.2.4": { - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "dependencies": [ - "internmap" - ] - }, - "d3-color@3.1.0": { - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" - }, - "d3-ease@3.0.1": { - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" - }, - "d3-format@3.1.2": { - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==" - }, - "d3-interpolate@3.0.1": { - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "dependencies": [ - "d3-color" - ] - }, - "d3-path@3.1.0": { - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==" - }, - "d3-scale@4.0.2": { - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "dependencies": [ - "d3-array", - "d3-format", - "d3-interpolate", - "d3-time", - "d3-time-format" - ] - }, - "d3-shape@3.2.0": { - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "dependencies": [ - "d3-path" - ] - }, - "d3-time-format@4.1.0": { - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "dependencies": [ - "d3-time" - ] - }, - "d3-time@3.1.0": { - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "dependencies": [ - "d3-array" - ] - }, - "d3-timer@3.0.1": { - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" - }, - "data-uri-to-buffer@4.0.1": { - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==" - }, - "date-fns@4.4.0": { - "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==" - }, - "dateformat@4.6.3": { - "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==" - }, - "debug@4.4.3": { - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dependencies": [ - "ms" - ] - }, - "decimal.js-light@2.5.1": { - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" - }, - "dedent@1.7.2": { - "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==" - }, - "depd@2.0.0": { - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" - }, - "detect-libc@2.1.2": { - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" - }, - "detect-node-es@1.1.0": { - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" - }, - "diff@8.0.4": { - "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==" - }, - "dom-serializer@2.0.0": { - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dependencies": [ - "domelementtype", - "domhandler", - "entities@4.5.0" - ] - }, - "domelementtype@2.3.0": { - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" - }, - "domhandler@5.0.3": { - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dependencies": [ - "domelementtype" - ] - }, - "domutils@3.2.2": { - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dependencies": [ - "dom-serializer", - "domelementtype", - "domhandler" - ] - }, - "dunder-proto@1.0.1": { - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dependencies": [ - "call-bind-apply-helpers", - "es-errors", - "gopd" - ] - }, - "ecdsa-sig-formatter@1.0.11": { - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dependencies": [ - "safe-buffer" - ] - }, - "ee-first@1.1.1": { - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "electron-to-chromium@1.5.443": { - "integrity": "sha512-TDJG36L9A3CWWwZ97HKaE+Iz1sW80pNp6sAU0owzCIV3Zc9eVyRqZK8Vz6NKuMIU8WjP0pIkoqCa0VRf/yFD0Q==" - }, - "encodeurl@2.0.0": { - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" - }, - "encoding-sniffer@0.2.1": { - "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", - "dependencies": [ - "iconv-lite@0.6.3", - "whatwg-encoding" - ] - }, - "end-of-stream@1.4.5": { - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dependencies": [ - "once" - ] - }, - "enhanced-resolve@5.26.0": { - "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", - "dependencies": [ - "graceful-fs", - "tapable" - ] - }, - "entities@4.5.0": { - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" - }, - "entities@6.0.1": { - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==" - }, - "entities@7.0.1": { - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==" - }, - "es-define-property@1.0.1": { - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" - }, - "es-errors@1.3.0": { - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" - }, - "es-module-lexer@2.3.2": { - "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==" - }, - "es-object-atoms@1.1.2": { - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dependencies": [ - "es-errors" - ] - }, - "es-toolkit@1.52.0": { - "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==" - }, - "esbuild@0.28.2": { - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "optionalDependencies": [ - "@esbuild/aix-ppc64", - "@esbuild/android-arm", - "@esbuild/android-arm64", - "@esbuild/android-x64", - "@esbuild/darwin-arm64", - "@esbuild/darwin-x64", - "@esbuild/freebsd-arm64", - "@esbuild/freebsd-x64", - "@esbuild/linux-arm", - "@esbuild/linux-arm64", - "@esbuild/linux-ia32", - "@esbuild/linux-loong64", - "@esbuild/linux-mips64el", - "@esbuild/linux-ppc64", - "@esbuild/linux-riscv64", - "@esbuild/linux-s390x", - "@esbuild/linux-x64", - "@esbuild/netbsd-arm64", - "@esbuild/netbsd-x64", - "@esbuild/openbsd-arm64", - "@esbuild/openbsd-x64", - "@esbuild/openharmony-arm64", - "@esbuild/sunos-x64", - "@esbuild/win32-arm64", - "@esbuild/win32-ia32", - "@esbuild/win32-x64" - ], - "scripts": true, - "bin": true - }, - "escalade@3.2.0": { - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" - }, - "escape-html@1.0.3": { - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "estree-walker@3.0.3": { - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dependencies": [ - "@types/estree" - ] - }, - "etag@1.8.1": { - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" - }, - "eventemitter3@5.0.4": { - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" - }, - "eventsource-parser@3.1.1": { - "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==" - }, - "eventsource@3.0.7": { - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "dependencies": [ - "eventsource-parser" - ] - }, - "exit-hook@5.1.0": { - "integrity": "sha512-INjr2xyxHo7bhAqf5ong++GZPPnpcuBcaXUKt03yf7Fie9yWD7FapL4teOU0+awQazGs5ucBh7xWs/AD+6nhog==" - }, - "expect-type@1.4.0": { - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==" - }, - "express-rate-limit@8.7.0_express@5.2.1": { - "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", - "dependencies": [ - "debug", - "express", - "ip-address" - ] - }, - "express@5.2.1": { - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dependencies": [ - "accepts", - "body-parser", - "content-disposition", - "content-type@1.0.5", - "cookie", - "cookie-signature", - "debug", - "depd", - "encodeurl", - "escape-html", - "etag", - "finalhandler", - "fresh", - "http-errors", - "merge-descriptors", - "mime-types", - "on-finished", - "once", - "parseurl", - "proxy-addr", - "qs", - "range-parser", - "router", - "send", - "serve-static", - "statuses", - "type-is", - "vary" - ] - }, - "exsolve@1.1.1": { - "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==" - }, - "extend@3.0.2": { - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "fast-copy@4.1.1": { - "integrity": "sha512-A4QTJmuiztpGtr6AMeJts9R4hbj2ZBUwtOaKrG6rw2y7t6+IaJKjz5M3XDs8BUznxDH43FVc6A0y/gWlMl4UtA==" - }, - "fast-deep-equal@3.1.3": { - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "fast-safe-stringify@2.1.1": { - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" - }, - "fast-sha256@1.3.0": { - "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==" - }, - "fast-uri@3.1.8": { - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==" - }, - "fast-xml-builder@1.3.1": { - "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", - "dependencies": [ - "path-expression-matcher", - "xml-naming" - ] - }, - "fast-xml-parser@5.11.2": { - "integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==", - "dependencies": [ - "@nodable/entities", - "fast-xml-builder", - "is-unsafe", - "path-expression-matcher", - "strnum", - "xml-naming" - ], - "bin": true - }, - "fdir@6.5.0_picomatch@4.0.7": { - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dependencies": [ - "picomatch" - ], - "optionalPeers": [ - "picomatch" - ] - }, - "fetch-blob@3.2.0": { - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dependencies": [ - "node-domexception", - "web-streams-polyfill" - ] - }, - "fflate@0.8.3": { - "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==" - }, - "finalhandler@2.1.1": { - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dependencies": [ - "debug", - "encodeurl", - "escape-html", - "on-finished", - "parseurl", - "statuses" - ] - }, - "formdata-polyfill@4.0.10": { - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dependencies": [ - "fetch-blob" - ] - }, - "forwarded@0.2.0": { - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" - }, - "fresh@2.0.0": { - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" - }, - "fsevents@2.3.3": { - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "os": ["darwin"], - "scripts": true - }, - "function-bind@1.1.2": { - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" - }, - "gaxios@7.3.1": { - "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", - "dependencies": [ - "extend", - "https-proxy-agent@7.0.6", - "node-fetch" - ] - }, - "gcp-metadata@8.1.2": { - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "dependencies": [ - "gaxios", - "google-logging-utils", - "json-bigint" - ] - }, - "gensync@1.0.0-beta.2": { - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" - }, - "get-intrinsic@1.3.0": { - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dependencies": [ - "call-bind-apply-helpers", - "es-define-property", - "es-errors", - "es-object-atoms", - "function-bind", - "get-proto", - "gopd", - "has-symbols", - "hasown", - "math-intrinsics" - ] - }, - "get-nonce@1.0.1": { - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==" - }, - "get-proto@1.0.1": { - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dependencies": [ - "dunder-proto", - "es-object-atoms" - ] - }, - "globrex@0.1.2": { - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==" - }, - "google-auth-library@10.9.1": { - "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", - "dependencies": [ - "base64-js", - "ecdsa-sig-formatter", - "gaxios", - "gcp-metadata", - "google-logging-utils", - "jws" - ] - }, - "google-logging-utils@1.1.3": { - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==" - }, - "gopd@1.2.0": { - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" - }, - "graceful-fs@4.2.11": { - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" - }, - "has-symbols@1.1.0": { - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" - }, - "hash-wasm@4.12.0": { - "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==" - }, - "hasown@2.0.4": { - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dependencies": [ - "function-bind" - ] - }, - "help-me@5.0.0": { - "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" - }, - "hono@4.13.12": { - "integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==" - }, - "htmlparser2@10.1.0": { - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", - "dependencies": [ - "domelementtype", - "domhandler", - "domutils", - "entities@7.0.1" - ] - }, - "http-errors@2.0.1": { - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dependencies": [ - "depd", - "inherits", - "setprototypeof", - "statuses", - "toidentifier" - ] - }, - "http-proxy-agent@9.1.0": { - "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", - "dependencies": [ - "agent-base@9.0.0", - "debug", - "proxy-agent-negotiate" - ] - }, - "https-proxy-agent@7.0.6": { - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dependencies": [ - "agent-base@7.1.4", - "debug" - ] - }, - "https-proxy-agent@9.1.0": { - "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", - "dependencies": [ - "agent-base@9.0.0", - "debug", - "proxy-agent-negotiate" - ] - }, - "iconv-lite@0.6.3": { - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dependencies": [ - "safer-buffer" - ] - }, - "iconv-lite@0.7.3": { - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "dependencies": [ - "safer-buffer" - ] - }, - "immer@11.1.18": { - "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==" - }, - "inherits@2.0.4": { - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "internmap@2.0.3": { - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==" - }, - "ip-address@10.7.2": { - "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==" - }, - "ipaddr.js@1.9.1": { - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" - }, - "is-promise@4.0.0": { - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" - }, - "is-unsafe@2.0.2": { - "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==" - }, - "isbot@5.2.2": { - "integrity": "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==" - }, - "isexe@2.0.0": { - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" - }, - "jiti@2.7.0": { - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "bin": true - }, - "jose@6.2.12": { - "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==" - }, - "joycon@3.1.1": { - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==" - }, - "jpeg-js@0.4.4": { - "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==" - }, - "js-tokens@4.0.0": { - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "jsesc@3.1.0": { - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "bin": true - }, - "json-bigint@1.0.0": { - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "dependencies": [ - "bignumber.js" - ] - }, - "json-schema-to-ts@3.1.1": { - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "dependencies": [ - "@babel/runtime", - "ts-algebra" - ] - }, - "json-schema-traverse@1.0.0": { - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "json-schema-typed@8.0.2": { - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==" - }, - "json5@2.2.3": { - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "bin": true - }, - "jwa@2.0.1": { - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "dependencies": [ - "buffer-equal-constant-time", - "ecdsa-sig-formatter", - "safe-buffer" - ] - }, - "jws@4.0.1": { - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "dependencies": [ - "jwa", - "safe-buffer" - ] - }, - "kysely@0.29.6": { - "integrity": "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==" - }, - "lightningcss-android-arm64@1.32.0": { - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "os": ["android"], - "cpu": ["arm64"] - }, - "lightningcss-android-arm64@1.33.0": { - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "os": ["android"], - "cpu": ["arm64"] - }, - "lightningcss-darwin-arm64@1.32.0": { - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "lightningcss-darwin-arm64@1.33.0": { - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "lightningcss-darwin-x64@1.32.0": { - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "lightningcss-darwin-x64@1.33.0": { - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "lightningcss-freebsd-x64@1.32.0": { - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "lightningcss-freebsd-x64@1.33.0": { - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "lightningcss-linux-arm-gnueabihf@1.32.0": { - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "os": ["linux"], - "cpu": ["arm"] - }, - "lightningcss-linux-arm-gnueabihf@1.33.0": { - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "os": ["linux"], - "cpu": ["arm"] - }, - "lightningcss-linux-arm64-gnu@1.32.0": { - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "lightningcss-linux-arm64-gnu@1.33.0": { - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "lightningcss-linux-arm64-musl@1.32.0": { - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "lightningcss-linux-arm64-musl@1.33.0": { - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "lightningcss-linux-x64-gnu@1.32.0": { - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "os": ["linux"], - "cpu": ["x64"] - }, - "lightningcss-linux-x64-gnu@1.33.0": { - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "os": ["linux"], - "cpu": ["x64"] - }, - "lightningcss-linux-x64-musl@1.32.0": { - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "os": ["linux"], - "cpu": ["x64"] - }, - "lightningcss-linux-x64-musl@1.33.0": { - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "os": ["linux"], - "cpu": ["x64"] - }, - "lightningcss-win32-arm64-msvc@1.32.0": { - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "lightningcss-win32-arm64-msvc@1.33.0": { - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "lightningcss-win32-x64-msvc@1.32.0": { - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "os": ["win32"], - "cpu": ["x64"] - }, - "lightningcss-win32-x64-msvc@1.33.0": { - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "os": ["win32"], - "cpu": ["x64"] - }, - "lightningcss@1.32.0": { - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dependencies": [ - "detect-libc" - ], - "optionalDependencies": [ - "lightningcss-android-arm64@1.32.0", - "lightningcss-darwin-arm64@1.32.0", - "lightningcss-darwin-x64@1.32.0", - "lightningcss-freebsd-x64@1.32.0", - "lightningcss-linux-arm-gnueabihf@1.32.0", - "lightningcss-linux-arm64-gnu@1.32.0", - "lightningcss-linux-arm64-musl@1.32.0", - "lightningcss-linux-x64-gnu@1.32.0", - "lightningcss-linux-x64-musl@1.32.0", - "lightningcss-win32-arm64-msvc@1.32.0", - "lightningcss-win32-x64-msvc@1.32.0" - ] - }, - "lightningcss@1.33.0": { - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dependencies": [ - "detect-libc" - ], - "optionalDependencies": [ - "lightningcss-android-arm64@1.33.0", - "lightningcss-darwin-arm64@1.33.0", - "lightningcss-darwin-x64@1.33.0", - "lightningcss-freebsd-x64@1.33.0", - "lightningcss-linux-arm-gnueabihf@1.33.0", - "lightningcss-linux-arm64-gnu@1.33.0", - "lightningcss-linux-arm64-musl@1.33.0", - "lightningcss-linux-x64-gnu@1.33.0", - "lightningcss-linux-x64-musl@1.33.0", - "lightningcss-win32-arm64-msvc@1.33.0", - "lightningcss-win32-x64-msvc@1.33.0" - ] - }, - "lodash@4.18.1": { - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" - }, - "long@5.3.2": { - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" - }, - "lru-cache@5.1.1": { - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dependencies": [ - "yallist" - ] - }, - "lucide-react@1.49.0_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-ApfaNM4/7jBrVopqLJ84Jz8TMQ1Q1JeZ1PmKh25aeS3E1FTVcMkSh1bEQB7dfzdIrwXRP8p3z4AJjTIHHb5IIA==", - "dependencies": [ - "@types/react", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "magic-string@0.30.21": { - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dependencies": [ - "@jridgewell/sourcemap-codec" - ] - }, - "magic-string@1.4.2": { - "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", - "dependencies": [ - "@jridgewell/sourcemap-codec" - ] - }, - "math-intrinsics@1.1.0": { - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" - }, - "media-typer@1.1.1": { - "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==" - }, - "merge-descriptors@2.0.0": { - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" - }, - "mime-db@1.54.0": { - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" - }, - "mime-types@3.0.2": { - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dependencies": [ - "mime-db" - ] - }, - "minimist@1.2.8": { - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" - }, - "ms@2.1.3": { - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "nanoid@3.3.19": { - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", - "bin": true - }, - "negotiator@1.1.0": { - "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", - "dependencies": [ - "content-type@2.1.0" - ] - }, - "next-themes@0.4.6_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", - "dependencies": [ - "react", - "react-dom" - ] - }, - "node-domexception@1.0.0": { - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": true - }, - "node-fetch@3.3.2": { - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dependencies": [ - "data-uri-to-buffer", - "fetch-blob", - "formdata-polyfill" - ] - }, - "node-releases@2.0.57": { - "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==" - }, - "node-unrar-js@2.0.2": { - "integrity": "sha512-hLNmoJzqaKJnod8yiTVGe9hnlNRHotUi0CreSv/8HtfRi/3JnRC8DvsmKfeGGguRjTEulhZK6zXX5PXoVuDZ2w==" - }, - "nth-check@2.1.1": { - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dependencies": [ - "boolbase" - ] - }, - "nunjucks@3.2.4": { - "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", - "dependencies": [ - "a-sync-waterfall", - "asap", - "commander" - ], - "bin": true - }, - "object-assign@4.1.1": { - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" - }, - "object-inspect@1.13.4": { - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" - }, - "obug@2.2.1": { - "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==" - }, - "on-exit-leak-free@2.1.2": { - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==" - }, - "on-finished@2.4.1": { - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": [ - "ee-first" - ] - }, - "once@1.4.0": { - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": [ - "wrappy" - ] - }, - "openai@7.19.0_zod@4.6.5": { - "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", - "dependencies": [ - "zod" - ], - "optionalPeers": [ - "zod" - ] - }, - "openapi3-ts@4.6.1": { - "integrity": "sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA==", - "dependencies": [ - "yaml" - ] - }, - "openworkflow@0.10.1_postgres@3.4.9": { - "integrity": "sha512-BdgEjoaazhL26JgQ/SehD/tLVEUYsHD6oZWU79+04jnxc+hPlxmRRfVfaAGeUdS2YEPFr/53C0kcRNodzAdNnA==", - "dependencies": [ - "postgres" - ], - "optionalPeers": [ - "postgres" - ] - }, - "p-map@7.0.8": { - "integrity": "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw==" - }, - "p-retry@4.6.2": { - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "dependencies": [ - "@types/retry", - "retry" - ] - }, - "parse5-htmlparser2-tree-adapter@7.1.0": { - "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", - "dependencies": [ - "domhandler", - "parse5" - ] - }, - "parse5-parser-stream@7.1.2": { - "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", - "dependencies": [ - "parse5" - ] - }, - "parse5@7.3.0": { - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dependencies": [ - "entities@6.0.1" - ] - }, - "parseurl@1.3.3": { - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" - }, - "partial-json@0.1.7": { - "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==" - }, - "path-expression-matcher@1.6.2": { - "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==" - }, - "path-key@3.1.1": { - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" - }, - "path-to-regexp@8.4.2": { - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==" - }, - "pathe@2.0.3": { - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" - }, - "pg-cloudflare@1.4.1": { - "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==" - }, - "pg-connection-string@2.14.1": { - "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==" - }, - "pg-int8@1.0.1": { - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" - }, - "pg-pool@3.14.0_pg@8.23.1": { - "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", - "dependencies": [ - "pg" - ] - }, - "pg-protocol@1.16.1": { - "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==" - }, - "pg-types@2.2.0": { - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "dependencies": [ - "pg-int8", - "postgres-array", - "postgres-bytea", - "postgres-date", - "postgres-interval" - ] - }, - "pg@8.23.1": { - "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", - "dependencies": [ - "pg-connection-string", - "pg-pool", - "pg-protocol", - "pg-types", - "pgpass" - ], - "optionalDependencies": [ - "pg-cloudflare" - ] - }, - "pgpass@1.0.5": { - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "dependencies": [ - "split2" - ] - }, - "picocolors@1.1.1": { - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" - }, - "picomatch@4.0.7": { - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==" - }, - "pino-abstract-transport@3.0.0": { - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", - "dependencies": [ - "split2" - ] - }, - "pino-pretty@13.1.3": { - "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", - "dependencies": [ - "colorette", - "dateformat", - "fast-copy", - "fast-safe-stringify", - "help-me", - "joycon", - "minimist", - "on-exit-leak-free", - "pino-abstract-transport", - "pump", - "secure-json-parse", - "sonic-boom", - "strip-json-comments" - ], - "bin": true - }, - "pino-std-serializers@7.1.0": { - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==" - }, - "pino@10.3.1": { - "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", - "dependencies": [ - "@pinojs/redact", - "atomic-sleep", - "on-exit-leak-free", - "pino-abstract-transport", - "pino-std-serializers", - "process-warning", - "quick-format-unescaped", - "real-require@0.2.0", - "safe-stable-stringify", - "sonic-boom", - "thread-stream" - ], - "bin": true - }, - "pkce-challenge@5.0.1": { - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==" - }, - "pkg-types@2.3.3": { - "integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==", - "dependencies": [ - "confbox", - "exsolve", - "pathe" - ] - }, - "playwright-core@1.63.0": { - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", - "bin": true - }, - "postcss@8.5.28": { - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", - "dependencies": [ - "nanoid", - "picocolors", - "source-map-js" - ] - }, - "postgres-array@2.0.0": { - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" - }, - "postgres-bytea@1.0.1": { - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==" - }, - "postgres-date@1.0.7": { - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==" - }, - "postgres-interval@1.2.0": { - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "dependencies": [ - "xtend" - ] - }, - "postgres@3.4.9": { - "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==" - }, - "process-warning@5.1.0": { - "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==" - }, - "protobufjs@7.6.6": { - "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", - "dependencies": [ - "@protobufjs/aspromise", - "@protobufjs/base64", - "@protobufjs/codegen", - "@protobufjs/eventemitter", - "@protobufjs/fetch", - "@protobufjs/float", - "@protobufjs/path", - "@protobufjs/pool", - "@protobufjs/utf8", - "@types/node", - "long" - ], - "scripts": true - }, - "proxy-addr@2.0.8": { - "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", - "dependencies": [ - "forwarded", - "ipaddr.js" - ] - }, - "proxy-agent-negotiate@1.1.0": { - "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==" - }, - "pump@3.0.4": { - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dependencies": [ - "end-of-stream", - "once" - ] - }, - "pvtsutils@1.3.6": { - "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "dependencies": [ - "tslib@2.8.1" - ] - }, - "pvutils@1.2.0": { - "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==" - }, - "qr-code-styling@1.9.2": { - "integrity": "sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==", - "dependencies": [ - "qrcode-generator" - ] - }, - "qrcode-generator@1.5.2": { - "integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==" - }, - "qs@6.16.0": { - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", - "dependencies": [ - "es-define-property", - "side-channel" - ] - }, - "quick-format-unescaped@4.0.4": { - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" - }, - "radix-ui@1.6.7_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==", - "dependencies": [ - "@radix-ui/primitive", - "@radix-ui/react-accessible-icon", - "@radix-ui/react-accordion", - "@radix-ui/react-alert-dialog", - "@radix-ui/react-arrow", - "@radix-ui/react-aspect-ratio", - "@radix-ui/react-avatar", - "@radix-ui/react-checkbox", - "@radix-ui/react-collapsible", - "@radix-ui/react-collection", - "@radix-ui/react-compose-refs", - "@radix-ui/react-context", - "@radix-ui/react-context-menu", - "@radix-ui/react-dialog", - "@radix-ui/react-direction", - "@radix-ui/react-dismissable-layer", - "@radix-ui/react-dropdown-menu", - "@radix-ui/react-focus-guards", - "@radix-ui/react-focus-scope", - "@radix-ui/react-form", - "@radix-ui/react-hover-card", - "@radix-ui/react-label", - "@radix-ui/react-menu", - "@radix-ui/react-menubar", - "@radix-ui/react-navigation-menu", - "@radix-ui/react-one-time-password-field", - "@radix-ui/react-password-toggle-field", - "@radix-ui/react-popover", - "@radix-ui/react-popper", - "@radix-ui/react-portal", - "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-progress", - "@radix-ui/react-radio-group", - "@radix-ui/react-roving-focus", - "@radix-ui/react-scroll-area", - "@radix-ui/react-select", - "@radix-ui/react-separator", - "@radix-ui/react-slider", - "@radix-ui/react-slot", - "@radix-ui/react-switch", - "@radix-ui/react-tabs", - "@radix-ui/react-toast", - "@radix-ui/react-toggle", - "@radix-ui/react-toggle-group", - "@radix-ui/react-toolbar", - "@radix-ui/react-tooltip", - "@radix-ui/react-use-callback-ref", - "@radix-ui/react-use-controllable-state", - "@radix-ui/react-use-effect-event", - "@radix-ui/react-use-escape-keydown", - "@radix-ui/react-use-is-hydrated", - "@radix-ui/react-use-layout-effect", - "@radix-ui/react-use-size", - "@radix-ui/react-visually-hidden", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react", - "@types/react-dom" - ] - }, - "range-parser@1.3.0": { - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==" - }, - "raw-body@3.0.2": { - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dependencies": [ - "bytes", - "http-errors", - "iconv-lite@0.7.3", - "unpipe" - ] - }, - "react-day-picker@10.0.2_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-QetiKtZWE9Uaw/pM/SYNv6WPYOTRnSJ2puL0GgwkmUFMIwiY0dtjH1W4B7DOVXVNQM0ZOClGpH/heOfBSzWeiA==", - "dependencies": [ - "@date-fns/tz", - "@types/react", - "date-fns", - "react" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "react-dom@19.3.0_react@19.3.0": { - "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", - "dependencies": [ - "react", - "scheduler" - ] - }, - "react-is@19.3.0": { - "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==" - }, - "react-redux@9.3.0_@types+react@19.3.0_react@19.3.0_redux@5.0.1": { - "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", - "dependencies": [ - "@types/react", - "@types/use-sync-external-store", - "react", - "redux", - "use-sync-external-store" - ], - "optionalPeers": [ - "@types/react", - "redux" - ] - }, - "react-refresh@0.18.0": { - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==" - }, - "react-remove-scroll-bar@2.3.8_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "dependencies": [ - "@types/react", - "react", - "react-style-singleton", - "tslib@2.8.1" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "react-remove-scroll@2.7.2_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", - "dependencies": [ - "@types/react", - "react", - "react-remove-scroll-bar", - "react-style-singleton", - "tslib@2.8.1", - "use-callback-ref", - "use-sidecar" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "react-router@8.4.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==", - "dependencies": [ - "@remix-run/route-pattern", - "cookie-es", - "react", - "react-dom" - ], - "optionalPeers": [ - "react-dom" - ] - }, - "react-style-singleton@2.2.3_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "dependencies": [ - "@types/react", - "get-nonce", - "react", - "tslib@2.8.1" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "react@19.3.0": { - "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==" - }, - "readdirp@5.1.1": { - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==" - }, - "readline-sync@1.4.10": { - "integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==" - }, - "real-require@0.2.0": { - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==" - }, - "real-require@1.0.0": { - "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==" - }, - "recharts@3.10.1_react@19.3.0_react-dom@19.3.0_react-is@19.3.0_@types+react@19.3.0_redux@5.0.1": { - "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", - "dependencies": [ - "@reduxjs/toolkit", - "clsx", - "decimal.js-light", - "es-toolkit", - "eventemitter3", - "immer", - "react", - "react-dom", - "react-is", - "react-redux", - "reselect", - "tiny-invariant", - "use-sync-external-store", - "victory-vendor" - ] - }, - "redux-thunk@3.1.0_redux@5.0.1": { - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "dependencies": [ - "redux" - ] - }, - "redux@5.0.1": { - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" - }, - "reflect-metadata@0.2.2": { - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" - }, - "require-from-string@2.0.2": { - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" - }, - "reselect@5.2.0": { - "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==" - }, - "retry@0.13.1": { - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==" - }, - "rolldown@1.2.12": { - "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", - "dependencies": [ - "@oxc-project/types", - "@rolldown/pluginutils" - ], - "optionalDependencies": [ - "@rolldown/binding-android-arm-eabi", - "@rolldown/binding-android-arm64", - "@rolldown/binding-darwin-arm64", - "@rolldown/binding-darwin-x64", - "@rolldown/binding-freebsd-x64", - "@rolldown/binding-linux-arm-gnueabihf", - "@rolldown/binding-linux-arm64-gnu", - "@rolldown/binding-linux-arm64-musl", - "@rolldown/binding-linux-ppc64-gnu", - "@rolldown/binding-linux-s390x-gnu", - "@rolldown/binding-linux-x64-gnu", - "@rolldown/binding-linux-x64-musl", - "@rolldown/binding-openharmony-arm64", - "@rolldown/binding-win32-arm64-msvc", - "@rolldown/binding-win32-x64-msvc" - ], - "bin": true - }, - "router@2.2.0": { - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dependencies": [ - "debug", - "depd", - "is-promise", - "parseurl", - "path-to-regexp" - ] - }, - "safe-buffer@5.2.1": { - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - }, - "safe-stable-stringify@2.5.0": { - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==" - }, - "safer-buffer@2.1.2": { - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "scheduler@0.28.0": { - "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==" - }, - "secure-json-parse@4.1.0": { - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==" - }, - "semver@6.3.1": { - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": true - }, - "semver@7.8.5": { - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "bin": true - }, - "send@1.2.1": { - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "dependencies": [ - "debug", - "encodeurl", - "escape-html", - "etag", - "fresh", - "http-errors", - "mime-types", - "ms", - "on-finished", - "range-parser", - "statuses" - ] - }, - "serve-static@2.2.1": { - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dependencies": [ - "encodeurl", - "escape-html", - "parseurl", - "send" - ] - }, - "setprototypeof@1.2.0": { - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "shebang-command@2.0.0": { - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dependencies": [ - "shebang-regex" - ] - }, - "shebang-regex@3.0.0": { - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" - }, - "side-channel-list@1.0.1": { - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dependencies": [ - "es-errors", - "object-inspect" - ] - }, - "side-channel-map@1.0.1": { - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dependencies": [ - "call-bound", - "es-errors", - "get-intrinsic", - "object-inspect" - ] - }, - "side-channel-weakmap@1.0.2": { - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dependencies": [ - "call-bound", - "es-errors", - "get-intrinsic", - "object-inspect", - "side-channel-map" - ] - }, - "side-channel@1.1.1": { - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "dependencies": [ - "es-errors", - "object-inspect", - "side-channel-list", - "side-channel-map", - "side-channel-weakmap" - ] - }, - "simple-liquid-glass@5.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-VgUgdnsKAIYezJijrJMV8YTZ+Ei5KEh4/q48YD/0aG+pCIgSET8rHBYC4i5M0Mrd3C6PNAmgWy92t7QdogjTLA==", - "dependencies": [ - "react", - "react-dom" - ], - "optionalPeers": [ - "react", - "react-dom" - ] - }, - "sonic-boom@4.2.1": { - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", - "dependencies": [ - "atomic-sleep" - ] - }, - "sonner@2.0.8_@types+react@19.3.0_react@19.3.0_react-dom@19.3.0": { - "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", - "dependencies": [ - "@types/react", - "react", - "react-dom" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "source-map-js@1.2.2": { - "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==" - }, - "split2@4.2.0": { - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" - }, - "standardwebhooks@1.1.1": { - "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", - "dependencies": [ - "@stablelib/base64", - "fast-sha256" - ] - }, - "statuses@2.0.2": { - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" - }, - "std-env@4.3.0": { - "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==" - }, - "strip-json-comments@5.0.3": { - "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==" - }, - "strnum@2.4.2": { - "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", - "dependencies": [ - "anynum" - ] - }, - "tailwind-merge@3.7.0": { - "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==" - }, - "tailwindcss@4.3.3": { - "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==" - }, - "tapable@2.3.3": { - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==" - }, - "thread-stream@4.2.0": { - "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", - "dependencies": [ - "real-require@1.0.0" - ] - }, - "tiny-invariant@1.3.3": { - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" - }, - "tinybench@6.2.0": { - "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==" - }, - "tinyexec@1.3.1": { - "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==" - }, - "tinyglobby@0.2.17": { - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dependencies": [ - "fdir", - "picomatch" - ] - }, - "toidentifier@1.0.1": { - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" - }, - "ts-algebra@2.0.0": { - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==" - }, - "tsconfck@3.1.6_typescript@7.0.2": { - "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", - "dependencies": [ - "typescript" - ], - "optionalPeers": [ - "typescript" - ], - "deprecated": true, - "bin": true - }, - "tslib@1.14.1": { - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, - "tslib@2.8.1": { - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, - "tsyringe@4.10.0": { - "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", - "dependencies": [ - "tslib@1.14.1" - ] - }, - "tw-animate-css@1.4.0": { - "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==" - }, - "type-is@2.1.0": { - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "dependencies": [ - "content-type@2.1.0", - "media-typer", - "mime-types" - ] - }, - "typebox@1.3.27": { - "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==" - }, - "typebox@1.3.34": { - "integrity": "sha512-wbnzrXXDW8xEFHDZZs2jo1MkhaYlKAY4FRhpBc1+2LF1fZVBGCXGdLEhA/Z/NBbgzJMFfeM8m7elKPa/+KxaUQ==" - }, - "typescript@7.0.2": { - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "optionalDependencies": [ - "@typescript/typescript-aix-ppc64", - "@typescript/typescript-darwin-arm64", - "@typescript/typescript-darwin-x64", - "@typescript/typescript-freebsd-arm64", - "@typescript/typescript-freebsd-x64", - "@typescript/typescript-linux-arm", - "@typescript/typescript-linux-arm64", - "@typescript/typescript-linux-loong64", - "@typescript/typescript-linux-mips64el", - "@typescript/typescript-linux-ppc64", - "@typescript/typescript-linux-riscv64", - "@typescript/typescript-linux-s390x", - "@typescript/typescript-linux-x64", - "@typescript/typescript-netbsd-arm64", - "@typescript/typescript-netbsd-x64", - "@typescript/typescript-openbsd-arm64", - "@typescript/typescript-openbsd-x64", - "@typescript/typescript-sunos-x64", - "@typescript/typescript-win32-arm64", - "@typescript/typescript-win32-x64" - ], - "bin": true - }, - "undici-types@7.24.6": { - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==" - }, - "undici@7.30.0": { - "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==" - }, - "unpipe@1.0.0": { - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" - }, - "update-browserslist-db@1.3.3_browserslist@4.29.3": { - "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", - "dependencies": [ - "browserslist", - "escalade", - "picocolors" - ], - "bin": true - }, - "use-callback-ref@1.3.3_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "dependencies": [ - "@types/react", - "react", - "tslib@2.8.1" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "use-sidecar@1.1.3_@types+react@19.3.0_react@19.3.0": { - "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "dependencies": [ - "@types/react", - "detect-node-es", - "react", - "tslib@2.8.1" - ], - "optionalPeers": [ - "@types/react" - ] - }, - "use-sync-external-store@1.7.0_react@19.3.0": { - "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", - "dependencies": [ - "react" - ] - }, - "valibot@1.5.0_typescript@7.0.2": { - "integrity": "sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==", - "dependencies": [ - "typescript" - ], - "optionalPeers": [ - "typescript" - ] - }, - "vary@1.1.2": { - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" - }, - "victory-vendor@37.3.6": { - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "dependencies": [ - "@types/d3-array", - "@types/d3-ease", - "@types/d3-interpolate", - "@types/d3-scale", - "@types/d3-shape", - "@types/d3-time", - "@types/d3-timer", - "d3-array", - "d3-ease", - "d3-interpolate", - "d3-scale", - "d3-shape", - "d3-time", - "d3-timer" - ] - }, - "vite-tsconfig-paths@6.1.1_vite@8.3.1_@types+node@24.19.0_typescript@7.0.2_yaml@2.9.1": { - "integrity": "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==", - "dependencies": [ - "debug", - "globrex", - "tsconfck", - "vite" - ] - }, - "vite@8.3.1_@types+node@24.19.0_yaml@2.9.1": { - "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", - "dependencies": [ - "@types/node", - "lightningcss@1.33.0", - "picomatch", - "postcss", - "rolldown", - "tinyglobby", - "yaml" - ], - "optionalDependencies": [ - "fsevents" - ], - "optionalPeers": [ - "@types/node", - "yaml" - ], - "bin": true - }, - "vitest@5.0.3_@types+node@24.19.0_vite@8.3.1_yaml@2.9.1": { - "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", - "dependencies": [ - "@types/chai", - "@types/node", - "@vitest/mocker", - "chai", - "es-module-lexer", - "expect-type", - "magic-string@1.4.2", - "obug", - "picomatch", - "std-env", - "tinybench", - "tinyexec", - "tinyglobby", - "vite", - "why-is-node-running" - ], - "optionalPeers": [ - "@types/node" - ], - "bin": true - }, - "web-streams-polyfill@3.3.3": { - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" - }, - "whatwg-encoding@3.1.1": { - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "dependencies": [ - "iconv-lite@0.6.3" - ], - "deprecated": true - }, - "whatwg-mimetype@4.0.0": { - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==" - }, - "which@2.0.2": { - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dependencies": [ - "isexe" - ], - "bin": true - }, - "why-is-node-running@3.2.1": { - "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", - "bin": true - }, - "wrappy@1.0.2": { - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "ws@8.22.0": { - "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==" - }, - "xml-naming@0.3.0": { - "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==" - }, - "xpath@0.0.34": { - "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==" - }, - "xtend@4.0.2": { - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" - }, - "yallist@3.1.1": { - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" - }, - "yaml@2.9.1": { - "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", - "bin": true - }, - "zod-to-json-schema@3.25.2_zod@4.6.5": { - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "dependencies": [ - "zod" - ] - }, - "zod@4.6.5": { - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==" - } - }, - "workspace": { - "dependencies": [ - "npm:7z-wasm@1.2.0", - "npm:@biomejs/biome@2.5.15", - "npm:@earendil-works/chord@1.1.0", - "npm:@earendil-works/pi-ai@1.1.0", - "npm:@earendil-works/pi-durable@1.1.0", - "npm:@hono/swagger-ui@0.6.1", - "npm:@hono/zod-openapi@1.6.3", - "npm:@modelcontextprotocol/sdk@1.31.0", - "npm:@simplewebauthn/server@14.0.3", - "npm:@types/node@24.19.0", - "npm:@types/nunjucks@3.2.6", - "npm:@types/pg@8.23.1", - "npm:@xmldom/xmldom@0.9.12", - "npm:cheerio@1.2.0", - "npm:fast-xml-parser@5.11.2", - "npm:fflate@0.8.3", - "npm:hash-wasm@4.12.0", - "npm:hono@4.13.12", - "npm:jpeg-js@0.4.4", - "npm:kysely@0.29.6", - "npm:node-unrar-js@2.0.2", - "npm:nunjucks@3.2.4", - "npm:openworkflow@0.10.1", - "npm:pg@8.23.1", - "npm:pino-pretty@13.1.3", - "npm:pino@10.3.1", - "npm:playwright-core@1.63.0", - "npm:postgres@3.4.9", - "npm:typebox@1.3.34", - "npm:typescript@7.0.2", - "npm:vitest@5.0.3", - "npm:xpath@0.0.34", - "npm:yaml@2.9.1", - "npm:zod@4.6.5" - ], - "members": { - "apps/web": { - "packageJson": { - "dependencies": [ - "npm:@fontsource-variable/inter-tight@^5.3.0", - "npm:@fontsource-variable/space-grotesk@^5.3.0", - "npm:@react-router/dev@8.4.0", - "npm:@react-router/node@8.4.0", - "npm:@simplewebauthn/browser@14", - "npm:@tailwindcss/vite@^4.3.3", - "npm:@tanstack/react-query@^5.104.0", - "npm:@types/react-dom@^19.3.0", - "npm:@types/react@^19.3.0", - "npm:class-variance-authority@~0.7.1", - "npm:clsx@^2.1.1", - "npm:date-fns@^4.4.0", - "npm:isbot@^5.2.2", - "npm:lucide-react@^1.49.0", - "npm:next-themes@~0.4.6", - "npm:qr-code-styling@^1.9.2", - "npm:radix-ui@^1.6.7", - "npm:react-day-picker@^10.0.2", - "npm:react-dom@^19.3.0", - "npm:react-router@8.4.0", - "npm:react@^19.3.0", - "npm:recharts@^3.10.1", - "npm:simple-liquid-glass@5.3.0", - "npm:sonner@^2.0.8", - "npm:tailwind-merge@^3.7.0", - "npm:tailwindcss@^4.3.3", - "npm:tw-animate-css@^1.4.0", - "npm:vite-tsconfig-paths@^6.1.1", - "npm:vite@^8.3.1", - "npm:zod@^4.6.5" - ] - } - } - } - } -} diff --git a/docs/adr/ADR-006-lifecycle-events.md b/docs/adr/ADR-006-lifecycle-events.md index ac4cbb1a..b496b6ae 100644 --- a/docs/adr/ADR-006-lifecycle-events.md +++ b/docs/adr/ADR-006-lifecycle-events.md @@ -1,7 +1,7 @@ -# ADR-006:生命周期事件、链式钩子与功能模块约定 +# ADR-006:生命周期事件与功能模块约定 -- 状态:采纳(2026-10-06,用户确认方向)。 -- 关联:ADR-002(outbox 与任务引擎)、`packages/contracts/src/events.ts`(`LIFECYCLE_EVENTS`)、`packages/contracts/src/hooks.ts`(`HOOK_POINTS`)、`packages/contracts/src/features.ts`(`FEATURES`、`SLOT_POSITIONS`)、`apps/server/src/hooks/`、`apps/web/app/lib/slots.ts`。 +- 状态:采纳(2026-10-06);2026-10-12 精简通用链式钩子,保留生命周期事件和功能模块约定。 +- 关联:ADR-002(outbox 与任务引擎)、`packages/contracts/src/events.ts`(`LIFECYCLE_EVENTS`)、`packages/contracts/src/features.ts`(`FEATURES`、`SLOT_POSITIONS`)、`apps/web/app/lib/slots.ts`。 - 参照:MoviePilot v3 `app/schemas/types.py` 的 `EventType`(广播)与 `ChainEventType`(链式),分发在 `app/runtime/events.py`。 ## 背景 @@ -13,23 +13,13 @@ ## 决定 -### 1. 两类事件 +### 1. 业务调用与事后通知 -| | 广播事件 | 链式钩子 | -| --- | --- | --- | -| 对照 MP | `EventType`(transfer.complete、subscribe.added …) | `ChainEventType`(name.recognize、transfer.rename …) | -| 时机 | 事后通知:事情已经发生并提交 | 事中:业务流程走到挂点时同步调用 | -| 传递 | 业务事务里写 `outbox_event`,中继投递成任务(异步、至少一次) | 进程内直接调用,按注册顺序 | -| 能做什么 | 订阅方自己建任务做后续动作 | 改写一个值(rewrite)或拒绝(intercept) | -| 失败 | 订阅任务按引擎重试;路由函数抛错只跳过该订阅方 | 钩子抛错 / 超时只记日志、跳过该钩子,主流程照常 | -| 去重 | outbox 幂等键(`/<部分…>`),订阅任务用引擎幂等键 | 不需要 | +同步规则由所属模块 service 明确实现,路由、能力与任务共用业务入口。跨模块需要同步取得结果时显式调用 service。 -**何时用哪类**:只想在「X 发生之后」做点事(发通知、补字幕、预热缓存)→ 订阅广播事件;要改变正在进行的决定(换个名字、换个候选、不让整理、晚点完成)→ 注册链式钩子。拿不准就用广播:它不在主流程里,坏了也不影响主流程。 +需要在业务提交后可靠执行的动作(补字幕、通知、预热等)订阅生命周期事件。业务事务内调用 `ctx.outbox.emit(trx, …)`,与业务写入一起提交或回滚;payload 只放 ID 和少量判断事实。中继至少一次投递成任务,接收方按 ID 回查业务表并幂等执行。 -**事务边界**: - -- 广播事件在业务事务**里**写 outbox(`ctx.outbox.emit(trx, …)`),与业务写入一起提交或回滚;payload 只放 ID 和少量判断用的事实,订阅方按 ID 回查业务表(事件可能晚到、重复到)。 -- 钩子可能在业务事务里被调用(订阅完成判定就在行锁里):只做内存里的判断与改写,**不要发外部请求、不要写库**;缺省每个钩子 2 秒上限。需要查外部的,改成订阅广播事件后异步做。 +原先参考 MP `ChainEventType` 实现的通用 rewrite/intercept 钩子没有实际业务注册者,本轮删除其注册表、挂点与调用层。以后出现真实扩展需求时再判断抽象方式。`modules/decision-hooks.ts` 是已有待处理事项的业务处理器,语义不同,继续保留。 ### 2. 广播事件清单 @@ -56,32 +46,11 @@ payload schema 都在 `EVENT_PAYLOADS`(`emit` 时校验,不合法直接抛 **订阅**:模块在 `outboxRoutes` 里写 `type → 路由函数`。生命周期事件可以有多个订阅方(`collectOutboxRoutes` 按模块顺序合成),其它事件(内部流程,如 `release.observed → resolveObservation`)仍只能一个模块认领。路由函数返回任务、任务数组或 `null`(不建任务);中继对第一个任务用事件的幂等键、其余用 `<键>#<任务名>`。没有订阅方的生命周期事件也会被中继认领、标记已投递,并镜像到 SSE(`worker/index.ts` 的镜像前缀)。 -### 3. 链式钩子清单 - -挂点名称与语义在 `HOOK_POINTS`,入参 / 返回值类型在 `apps/server/src/hooks/points.ts`。 - -| 挂点 | 类型 | 位置 | 能做什么 | -| --- | --- | --- | --- | -| `name.recognize` | rewrite | `resolveRelease`(标题解析后、召回前)、`identifyParsed`(整理 / 接管按名称识别) | 改写解析结果(标题、年份、季集、外部 ID) | -| `organize.rename` | rewrite | `planImport`,模板命名生成目标之后(保留原名 / 原地入库不调) | 改写主视频目标文件名;同主干的附属文件跟着改;结果带路径分隔符或空名的钩子跳过;改名后撞名转 `duplicate_target` | -| `organize.intercept` | intercept | `planImport`,写整理记录之前(预演也调) | 返回原因即拒绝:记录为失败,`reason = intercepted:<原因>` | -| `resource.selection` | rewrite | 订阅 / 收录版本重评(`reevaluate`)择优排序之后 | 重排或过滤候选;不认识的 ID 忽略,不能加入新候选 | -| `subscription.completion.check` | intercept | `completeSubscription`,标记完成之前 | 追加完成条件:返回原因即这次不完成,订阅继续追踪 | - -语义: - -- 注册在模块启动时完成:`ServerModule.registerHooks(hooks, ctx)`,`bootstrap` 按 `SERVER_MODULES` 顺序调用;执行顺序即注册顺序(不设优先级数字,需要先后就调整模块顺序)。 -- rewrite:每个钩子拿到上一个钩子的结果,返回新值或 `undefined`(不改)。intercept:第一个返回非空原因的钩子拒绝,后面不再执行。 -- 每个钩子单独限时(缺省 2 秒,可在注册时改),超时或抛错记 warn 日志、跳过这个钩子;拦截类出错按放行处理。 -- 没有注册时调用方直接用原值(`hasHook` 为 false,不构造上下文)。 - -MP 的其它链式事件(`transfer.overwrite.check`、`resource.download`、`discover.source`、`recommend.source` 等)等有真实需求再加:在 `HOOK_POINTS` 登记、在 `points.ts` 写类型、在业务节点调用。 - -### 4. 功能模块约定 +### 3. 功能模块约定 一个外围功能 = 服务端一个 `ServerModule` + contracts 里一份 manifest + (可选)前端插槽注册: -- **manifest**(`FEATURES`):名称、说明、怎么用、订阅的事件、提供的能力、页面入口、插槽位置、钩子。服务端模块 `manifest: featureManifest("")`;`modules.test.ts` 核对模块与清单一一对应、订阅的事件与 `outboxRoutes` 一致。 +- **manifest**(`FEATURES`):名称、说明、怎么用、订阅的事件、提供的能力、页面入口、插槽位置。服务端模块 `manifest: featureManifest("")`;`modules.test.ts` 核对模块与清单一一对应、订阅的事件与 `outboxRoutes` 一致。 - **功能清单只给智能体**:系统提示「能力地图」一节末尾列出外围功能(`renderFeatures`),能力只列本次可用的。**不做开关**:功能都直接启用。 - **前端插槽**(`apps/web/app/lib/slots.ts`):位置见 `SLOT_POSITIONS`(侧栏入口、作品页操作区、作品页面板区、整理记录行操作、设置分区)。动作类位置注册 `{ label, icon, visible?, Dialog }`,页面渲染成按钮 / 菜单项、点了打开对话框;面板类位置注册组件,挂载点按顺序渲染。注册写在 `app/features/<功能>.tsx`,`app/features/index.ts` 汇总。**默认直接显示,不做开关**。已挂载:作品页操作区、作品页面板区(`library.$id.tsx`)、整理记录行菜单(`organize.tsx`);侧栏与设置分区先只定义位置。 - 样板:补字幕(`modules/subtitles`)——manifest、订阅 `organization.finished`(认出值得补字幕的整理,自动补字幕还没做,只记调试日志不建任务)、写入后发 `subtitle.written`、前端在作品页操作区和整理记录行菜单挂「补字幕」。 @@ -89,5 +58,4 @@ MP 的其它链式事件(`transfer.overwrite.check`、`resource.download`、`d ## 不做 - 插件系统、热加载、插件市场、功能开关。 -- 钩子优先级数字、钩子里做外部请求。 - 把广播事件放进内存队列(MP 做法):我们已有 outbox,事件要能跨进程、跨重启。 diff --git a/docs/adr/ADR-007-bun-runtime.md b/docs/adr/ADR-007-bun-runtime.md new file mode 100644 index 00000000..632812fc --- /dev/null +++ b/docs/adr/ADR-007-bun-runtime.md @@ -0,0 +1,15 @@ +# ADR-007: Bun 1.4.3 与 Linux 解析沙箱 + +状态:采纳。取代 ADR-004 的运行时、依赖安装与编译方式;更新 ADR-005 的权限隔离实现。 + +后端、前端开发工具及 Vitest 使用 Bun 1.4.3。根 package.json 管理 workspace 与统一依赖,bun.lock 锁定解析结果。.bun-version、packageManager 与 Docker 构建镜像固定同一版本。现有业务依赖版本保持不变。 + +Deno 全局调用改为 Node API 或 @umt/runtime 中的 Bun HTTP、WebSocket、子进程与流适配。HTTP 出口使用 proxy:false 强制直连,显式代理使用 respectNoProxy:false;环境代理由现有出口选择函数处理。 + +Bun 不提供 Deno 权限撤销。解析沙箱采用一次性子进程,仅传入 PATH 环境变量,预加载内置解析器与 WASM、接收 stdin 后才安装 Linux seccomp 白名单。策略通过 Node-API 模块实施,启用 no_new_privs 与 TSYNC,覆盖所有线程。默认 EPERM 拒绝未列出的系统调用:文件路径访问、socket/connect、exec、fork、ptrace、mount、io_uring 均不允许。clone 只允许 CLONE_THREAD,clone3 返回 ENOSYS 供 libc 使用受检的 clone。策略安装后直接核验 socket/openat/execve 被内核以 EPERM 拒绝。已有 stdin/stdout 与运行时内存管理调用允许使用。不同系统调用架构直接终止。 + +这不是完整虚拟机:内核与 Bun 内存安全仍是信任边界。环境变量不再通过运行时权限隐藏,改为父进程清空环境;输入只送到固定任务表,不执行上传代码。父进程继续实施超时与 RSS 上限并 SIGKILL。建立隔离失败直接拒绝,不回退到主进程执行。策略目前支持 Linux x64/arm64;其它系统不能执行解析沙箱任务,可使用 Linux Docker 开发。 + +编译脚本静态嵌入所有数据库迁移与 WASM,关闭可执行文件自动加载 .env/bunfig。生产部署同时携带 sandbox-policy.node 与内置 skills 目录。C 编译器及 Node-API 头文件仅在构建阶段需要。check:binary 在空环境、/tmp 工作目录验证模块图、原生策略及 WASM 可加载。 + +文件代理与解析代理共用 `@umt/runtime/worker` 的逐行 JSON 协议、消息大小限制、异常退出与有界回收。仍为一次性进程,不在通信层自动重试;文件进度停滞、SIGTERM 清理宽限与解析总时长/RSS/SIGKILL 策略由各自代理维护。服务端 FS_PROXY_MODE 缺省 subprocess,只有显式 auto 才允许进程内回退。开发指南记录抽象边界与后续提取常驻管理的条件。 diff --git a/docs/plan/README.md b/docs/plan/README.md index 4726ff1e..2f73f4a1 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -11,7 +11,7 @@ | 设计规范(界面、领域) | [docs/design/](../design/) | | 怎么用、怎么运维 | [docs/runbook/](../runbook/)(开发环境见 [dev.md](../runbook/dev.md),部署见 [deploy.md](../runbook/deploy.md)) | | 与 MoviePilot 的业务对照(持续维护) | [docs/porting/moviepilot-v3-comparison.md](../porting/moviepilot-v3-comparison.md) 及同目录各移植文档 | -| 版本变化 | 仓库根 [CHANGELOG.md](../../CHANGELOG.md),每个版本几行;发布前必须写好(`deno task release` 会检查) | +| 版本变化 | 仓库根 [CHANGELOG.md](../../CHANGELOG.md),每个版本几行;发布前必须写好(`bun run release` 会检查) | | 经验教训 | [lessons.md](lessons.md) | | 证据、截图、构建日志 | `.local/`(不进 git) | diff --git a/docs/runbook/deploy.md b/docs/runbook/deploy.md index a9cc6c41..1d2b488e 100644 --- a/docs/runbook/deploy.md +++ b/docs/runbook/deploy.md @@ -1,31 +1,26 @@ -# 部署运行手册(W4-B) +# 部署运行手册 -形态:**核心应用在一个容器里,PostgreSQL 与浏览器分别部署**。镜像 `uhd-media-tool` 内一个 `deno compile` 出的二进制 `umt-server`, +形态:**核心应用在一个容器里,PostgreSQL 与浏览器分别部署**。镜像 `uhd-media-tool` 内一个 `bun build --compile` 出的二进制 `umt-server`, 容器里同时跑 api、worker(io 组)、worker:agent(Agent 会话)三个进程,api 同端口托管 web 静态产物(`WEB_ROOT=/app/public`),对外只有 **4380** 一个端口。 compose 默认有三个服务:`app` + `postgres` + `camofox`(浏览器服务,只在内网,不映射端口;可关掉或换成自有实例,见 §2.1);`tls` profile 可额外启用 Caddy 提供 HTTPS。 ## 1. 构建镜像 -构建阶段会下载 Deno 运行时等外网资源。直连不稳时在 `.env.deploy` 配 `BUILD_PROXY`(如经宿主中继 `http://172.17.0.1:7891`),`deno task release` 会自动带上 `--build-arg HTTP(S)_PROXY`;代理只用于构建,不进镜像。 +构建阶段需要 Bun 镜像、依赖包与构建工具等资源。直连不稳时在 `.env.deploy` 配 `BUILD_PROXY`(如经宿主中继 `http://172.17.0.1:7891`),`bun run release` 会自动带上 `--build-arg HTTP(S)_PROXY`;代理只用于构建,不进镜像。 ```sh docker build -t uhd-media-tool:latest . # 或 docker compose --env-file .env.deploy build APP_VERSION=v0.3.11 docker compose --env-file .env.deploy build app # 示例:发布时填写对应版本,写进 web 的 version.json ``` -- 多阶段:`denoland/deno:2.9.7`(装依赖 → web 构建 → server 编译)→ `debian:trixie-slim` 运行镜像。 +- 多阶段:`oven/bun:1.4.3`(装依赖 → web 构建 → server 编译)→ `debian:trixie-slim` 运行镜像。 - 运行镜像额外装 `gosu`(降权)及 PG 客户端动态库,从固定的 `postgres:18.6-trixie` 官方镜像复制 pg_dump/pg_restore/psql 与 libpq 18, 外加 `/usr/local/bin/ffprobe`:从 `mwader/static-ffmpeg:9.0.2` 拷单个静态二进制(约 135 MB;Debian `ffmpeg` 包要 200+ 个依赖约 450 MB)。 换来源用 `--build-arg FFPROBE_IMAGE=<含 /ffprobe 的镜像>`。 - `/app` 下的二进制、静态产物、脚本全部 root 属主只读;运行用户改不了它们。 -- 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,cat,/app/umt-server` - (库根/下载目录由用户挂载,位置不固定,所以读写不按路径限制;写保护靠只读 `/app` + 非 root 运行)。 - 子进程只放三个:ffprobe(入库后探测视频流)、sha256sum(coreutils 自带;目标已存在/跨卷恢复时的全量哈希在子进程里算, - 不占 worker 事件循环;不可用时回退进程内流式哈希,整理结果里 `hashVia` 记录实际方式)、二进制自身 - (fsproxy 挂死隔离:入库的每个文件操作起一次 `/app/umt-server --role fsworker`,约 150 ms;进度停滞超过 `FS_HANG_TIMEOUT_MS` - → SIGTERM → `FS_KILL_GRACE_MS` 后 SIGKILL → 按 TIMEOUT 有限次重试,超限建待处理)。Deno 的 `--allow-run` 按绝对路径放行, - 所以二进制必须位于 `/app/umt-server`;挪到别处运行时 spawn 被拒(NotCapable),自动回退进程内看门狗(卡死的系统调用线程无法回收), - 日志 `organize.transfer` 的 `fsproxy.mode/workerKind/fallbackReason` 可看出实际方式。 +- 构建同时生成解析隔离模块 `sandbox-policy.node`,与二进制放在 `/app`。主进程以非 root 运行,外部字节解析另起子进程并安装 seccomp 策略;详见 ADR-005 与 ADR-007。Bun 主进程不提供 Deno 的运行时权限开关。 +- 镜像带静态 ffprobe;全量文件哈希优先走 sha256sum 子进程,不可用时回退流式哈希,整理结果的 `hashVia` 记录实际方式。 +- 文件操作通过 `/app/umt-server --role fsworker` 一次性子进程执行,进度停滞超过 `FS_HANG_TIMEOUT_MS` 后先 SIGTERM、再按 `FS_KILL_GRACE_MS` 发送 SIGKILL。`FS_PROXY_MODE` 缺省 `subprocess`,启动失败报错;只有显式选择 `auto` 才允许进程内回退。业务服务决定重试与恢复,通信层不自动重做副作用。 ## 2. 启动 @@ -146,14 +141,13 @@ docker compose --env-file .env.deploy logs app | grep 'setup token' 发布前检查旧镜像的实际在途任务:在任务页核对 queued / running / waiting,尤其是本次变更的任务名称和版本。优先等它们完成;需要中止的任务先保留原输入、冻结计划和操作记录,确认中止后通过现有入口重新发起。已经产生文件操作证据的任务要先核实执行结果,再决定后续处理。没有旧在途任务时不长期保留旧 handler;业务表前滚不能替代任务版本兼容判断。 -本次整理来源功能增加 `scanOrganizeSource@1` 和迁移 `0059_organize_source_snapshot`,沿用 `verifyAndImport`。迁移不会扫描或整理已有目录;用户新增来源后先建立基线,存量必须单独触发。 - +整理来源新增后先建立基线,存量扫描必须单独触发;迁移本身不扫描或整理已有目录。 本机测试环境的标准做法: ```sh cd <仓库> && git pull -deno task release # 工作区须干净、CHANGELOG 已有本版条目;打 tag → 构建 → 只替换 app → 核对健康版本 +bun run release # 工作区须干净、CHANGELOG 已有本版条目;打 tag → 构建 → 只替换 app → 核对健康版本 ``` `scripts/release.sh --no-build` 用已有镜像只重建容器。真有要保留的数据时,先 `docker compose --env-file .env.deploy exec app umt backup` 再发布。手动等价命令: @@ -176,60 +170,6 @@ docker compose --env-file .env.deploy ps && docker compose --env-file .env.deplo - 一次性子命令:`docker compose --env-file .env.deploy exec app umt migrate|preflight|export-mappings|backup`、 `docker compose --env-file .env.deploy run --rm --no-deps app restore <备份>`。 -## 验证记录 - -2026-09-27,W4-B,Docker 29 / compose v2,在干净 worktree(HEAD 8c6a922 + 本次改动)构建, -临时项目名 `umtw4b`、app 端口 127.0.0.1:4390、pg 端口 127.0.0.1:55440,`/media` 绑定一个属主 10001 的临时目录。 - -| 步骤 | 结果 | -|---|---| -| `docker build --build-arg APT_MIRROR=mirrors.ustc.edu.cn` | 成功(缓存命中 2 分 24 秒);镜像 489 MB(压缩后 107 MB):二进制 213 MB、gosu+postgresql-client 79 MB、web 2 MB | -| `compose up -d` | postgres healthy 后 app 启动,约 6 秒 healthy;22 个迁移(编号 0017 空缺)+ 引擎迁移,`启动自检通过 dataDir=/data sessionDir=/data/agent-sessions` | -| 进程 | 两个 `umt-server`(api、worker)uid/gid 均为 10001;`/data/*` 属主 10001;`/app/umt-server` 为 `r-xr-xr-x root`,运行用户写入 → Permission denied | -| `GET /` 与 `/console/settings` | 200,SPA 由同端口托管 | -| setup token(日志)→ `POST /auth/setup` → `POST /auth/login` | 成功,拿到会话与 csrf cookie | -| 会话 + csrf `POST /api-keys {scopes:[admin]}` | 返回 `umt_…` 明文一次 | -| API Key `GET /organization-records` | 200 | -| API Key `POST /library-roots {localPath:/media}` | 成功,宿主目录出现 `.uhd-root`(属主 10001);容器内 root 属主目录登记 → 400 `库根不可写 … EACCES` | -| `PATCH /settings {pi:{provider:fake}}` → `POST /agent/runs {mode:plan_only}` | run `succeeded`,会话文件 `/data/agent-sessions/.jsonl`(18.7 KB,属主 10001) | -| 宿主 `chmod 555` 媒体目录后 `umt preflight` | 退出码 0,`warn 挂载路径不可用: verify-media 不可写: EACCES`,审计 `preflight.path_unavailable` 一条;恢复权限后 warnings=0 | -| `umt backup` | `db.dump` 245 KB + `mappings.json` + `agent-sessions.tar.gz` + `manifest.txt` + `SHA256SUMS`;pg_dump 17.11 对服务端 16.14 | -| 清库 | `drop schema public cascade` + 删 `openworkflow` schema → 0 张表 | -| app 运行中 `umt restore` | 拒绝:`数据库还有 13 个其它连接` | -| `compose stop app` → `compose run --rm --no-deps app restore --yes` | 退出码 0;63 张表恢复;抽查 admin_user 1 / api_key 1 / library_root 1 / media_entity 3 / external_identity 3 / agent_run 1 / agent_run_event 9 与备份前一致(audit_event +1 为恢复审计、settings +1 为 automation 行);映射清单逐表比对"一致";`automation={"readOnlyReconcile": true}`,审计 `ops.read_only_reconcile_on` | -| 恢复后 `up -d app` | healthy;`GET /settings` → `automation {enabled: true, readOnlyReconcile: true}` | -| `BACKUP_KEEP=3` 连续备份 3 次 | 最旧一份被轮转删除,剩 3 份 | -| `PUID=1234 PGID=1234` 空卷启动 | `/data` 及子目录 chown 为 1234,进程 uid 1234;`PUID=0` → 退出码 2 | -| `compose stop app` | 0.8 秒,api/worker 均收到 SIGTERM 正常退出 | -| 清理 | `compose down -v`、删除测试镜像;未 prune 全局 build cache | - -首次演练发现:镜像里的 pg_restore 17 直连 16 版服务端会因 `SET transaction_timeout` 失败(单事务回滚,库未被破坏), -已改为 pg_restore 输出 SQL、过滤该行后由 psql 单事务回放,复测通过。 - -2026-09-27,W4-D,临时项目名 `umtw4d`、app 127.0.0.1:4391、pg 127.0.0.1:55441(验证后已 `down -v` 并删除镜像)。 - -| 步骤 | 结果 | -|---|---| -| `docker build --build-arg APT_MIRROR=mirrors.ustc.edu.cn` | 成功;镜像内容 160.5 MB 压缩(W4-B 107 MB),新增层:静态 ffprobe 135 MB(未压缩),二进制 214 MB | -| `compose up -d` | 约 8 秒 healthy;`/api/v1/health` → `status=ok role=api db=ok` | -| 进程 | `entrypoint.sh serve` 下三个 `umt-server`:`--role api`、`--role worker`(日志 `group=io`,9 个任务,并发 4,模块循环 4 个)、`--role worker:agent`(`runAgent@1`/`resolveDecision@1`,并发 2,无模块循环),均为 uid 10001 | -| ffprobe | 容器内 `gosu 10001 ffprobe` 探测一个 2 KB WAV → `pcm_s16le`;`ffprobe version 7.1.1` | -| 编译权限 | 同一组编译参数的探针二进制在容器内以 10001 运行:node `execFile("ffprobe")` 成功、`Deno.Command("sha256sum")` 成功、`Deno.Command("ls")` → `NotCapable` | - -2026-09-27,W5-E(生产 fsworker),临时项目名 `umtw5e`、app 127.0.0.1:4395、pg 127.0.0.1:55442,`/media` 绑定属主 10001 的临时目录; -在干净 worktree(HEAD 89ea684 + 本次改动)构建。`FS_HANG_TIMEOUT_MS=3000`、`FS_KILL_GRACE_MS=500`。 - -| 步骤 | 结果 | -|---|---| -| `docker build --build-arg APT_MIRROR=mirrors.ustc.edu.cn` | 成功(约 2 分 30 秒,缓存命中依赖层) | -| setup → login → admin API Key → `POST /library-roots {localPath:/media}` → 改 `managed`;SQL 插 3 个 movie 作品 | 成功 | -| 手动整理 `POST /organization-records` dryRun → 不带 dryRun 执行(Copy,1.5 GB 稀疏文件 `/media/_incoming/Check.Movie.2021…mkv`) | 记录 succeeded,1.8 s;日志 `organize.transfer` 带 `fsproxy:{mode:"subprocess",workerKind:"self",spawned:5,timeouts:0,fallbackReason:null}` | -| 以 `UMT_FS_TEST_HANG_AFTER_BYTES=1048576 UMT_FS_TEST_HOOKS=1` 重建 app 后整理 60 MB 文件 | 容器内 `/proc` 看到 `/app/umt-server --role fsworker`(uid 10001,父进程为 `--role worker`)存活约 3–4 s 后消失,无残留子进程 | -| 超时链路 | 每次尝试:停滞 3 s → SIGTERM(worker 屏蔽)→ SIGKILL → `TIMEOUT`;共 3 次(`FS_TRANSFER_MAX_ATTEMPTS`)后账本 `{"code":"TIMEOUT","message":"fs copy 挂死:进度停滞超过 3000 ms"}`,`fsproxy.timeouts=3 killed=3`,待处理「文件操作挂死,重试已达上限」;目标目录只留被 KILL 的 `.uhd-partial`(下次写前按年龄清理),无最终名半成品 | -| 清理 | `compose down -v`、删除测试镜像与临时目录 | - -`UMT_FS_TEST_HANG_AFTER_BYTES` 只用于这类验收(让数据类操作的 worker 读到 n 字节后卡死 JS 线程并屏蔽 SIGTERM),必须同时设置 `UMT_FS_TEST_HOOKS=1` 才生效;单独设置不生效,但 `preflight` 会打 warn。生产不要设置。 - ## 容器经宿主代理访问境外站点(可选) 宿主上的代理(如 mihomo)常只监听 `127.0.0.1`,容器从网桥够不到。改动最小的做法:加一个只绑在 docker 网桥地址上的中继,再放行 docker 网段到该端口;代理本身配置不动。写在本机 `docker-compose.override.yml`(不进 git): @@ -253,7 +193,7 @@ services: compose 已给 app 设了缺省 `NO_PROXY`(`UMT_NO_PROXY` 可改),override 里重写 `NO_PROXY` 时别漏掉下载器的服务名。 -防火墙只放行 docker 网段:`ufw allow from 172.16.0.0/12 to 172.17.0.1 port 7891 proto tcp`。compose 默认网络已固定在 `UMT_SUBNET`(缺省 172.30.0.0/24,与本机其他网络冲突时在 `.env.deploy` 改成 172.16.0.0/12 内的空闲网段,如 172.16.80.0/24),否则 compose 自动分配的 192.168.x 网段不在放行范围内。Deno 的 fetch 会读 `HTTP(S)_PROXY`/`NO_PROXY`;TMDB、境外 Pi 上游、camofox 访问境外站都经此代理,站内(postgres/camofox)不走代理。 +防火墙只放行 docker 网段:`ufw allow from 172.16.0.0/12 to 172.17.0.1 port 7891 proto tcp`。compose 默认网络已固定在 `UMT_SUBNET`(缺省 172.30.0.0/24,与本机其他网络冲突时在 `.env.deploy` 改成 172.16.0.0/12 内的空闲网段,如 172.16.80.0/24),否则 compose 自动分配的 192.168.x 网段不在放行范围内。应用的外部请求统一读取 `HTTP(S)_PROXY`/`NO_PROXY`;TMDB、境外 Pi 上游、camofox 访问境外站都经此代理,站内(postgres/camofox)不走代理。 ## 测试用 qB(override 示例) @@ -263,7 +203,7 @@ compose 已给 app 设了缺省 `NO_PROXY`(`UMT_NO_PROXY` 可改),override 里 services: app: environment: - # 配了 HTTP(S)_PROXY 时必须把 qbittorrent 加进 NO_PROXY:Deno 按主机名匹配,不会因解析到内网 IP 而直连 + # 配了 HTTP(S)_PROXY 时必须把 qbittorrent 加进 NO_PROXY:代理按主机名匹配,不会因解析到内网 IP 而直连 NO_PROXY: localhost,127.0.0.1,::1,postgres,camofox,proxy-relay,qbittorrent,qbittorrent-nox,qb,transmission,deluge,aria2,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 qbittorrent: image: lscr.io/linuxserver/qbittorrent:5.2.4 # v4 / 5.0–5.2 都兼容(QB-1 实测 5.1.2、5.2.4) @@ -319,14 +259,6 @@ API Key 在 qB 5.2+ WebUI 选项 → WebUI → API Key 处生成 / 轮换(等价 5.2.4 账密 / API Key / 无认证 / 失败分类,各跑 登录 → 添加(testkit `makeTorrent({ realPieces: true })` 真实 piece 哈希, 内容写进保存目录,qB 校验即完成)→ 查询 → 标签 → 暂停恢复 → 删除(不删数据)。 -## v0.3.10 发布补充 +## 本机测试入口 -2026-10-01 已发布 v0.3.10,执行 `0060_manual_acquisition`:`acquisition_plan.slot_id` 允许为空,支持独立手动下载;`download_task.import_plan_id` 保存本次入库规划,并以外键引用计划。部署前备份与校验通过,只替换 app 容器;线上接口、版本一致及页面冒烟通过。后续普通 Compose 启动使用已更新的 `.env.deploy` 版本与源码标记。具体证据见 发布记录(已删除,见 git `ceb66f8`)。 - -### 当前测试入口 - -用户要求测试自签 HTTPS 后,4380 已由 Caddy 接管为 `https://服务器地址:4380`;原 443 入口保留,app 本机 HTTP 调试入口为 `http://127.0.0.1:4381`。配置保存在 `.env.deploy` 与本机 `docker-compose.override.yml`,重建了 app/Caddy 容器,镜像及数据库版本保持 v0.3.10/0060。证书由原 Caddy 内部 CA 签发,健康、管理员接口和 HTTPS 页面冒烟通过。 - -## v0.3.11 修复发布 - -搜索结果标题接入已有资源详情抽屉,已发布 v0.3.11;数据库保持 `0060`。浏览器桌面修复镜像为 `umt-browser-desktop:1.18.0-umt.1`,内置有效的最大化配置,已验证上下黑边消失。`.env.deploy` 已记录当前应用版本、源码标记和浏览器镜像,4380 HTTPS/443 HTTPS/本机 4381 HTTP 映射保留。具体验证见 发布记录(已删除,见 git `ceb66f8`)。 +本机测试部署由 Caddy 提供 `https://<服务器地址>:4380`,同时保留 443 HTTPS;app 的本机 HTTP 调试入口为 `http://127.0.0.1:4381`。配置在 `.env.deploy` 与本机 `docker-compose.override.yml`,证书由 Caddy 内部 CA 签发。发布时按配置核对健康接口版本。 diff --git a/docs/runbook/dev.md b/docs/runbook/dev.md index e0b96bd1..ad006983 100644 --- a/docs/runbook/dev.md +++ b/docs/runbook/dev.md @@ -2,19 +2,19 @@ ## 前置 -- Deno 2.9.7(锁定;`curl -fsSL https://deno.land/install.sh | sh -s v2.9.7`)、Docker。不需要 Node.js / pnpm。 +- Bun 1.4.3(锁定)、Docker;Linux 解析沙箱构建需要 C 编译器和 Node-API 头文件(Debian: `build-essential libnode-dev`)。不需要单独安装 Node.js / pnpm。 - 端口:web 5180、API 4390、PG 55433。4380 留给本机部署版(Caddy),本机 3000/5175/5176/55432 已被其他项目占用,不要改用。 ## 首次启动 ```sh cp .env.example .env # 按需修改;server 与 db 脚本都读仓库根 .env -deno install # 按 deno.lock 装依赖到 node_modules(成员包软链到 node_modules/@umt/*) -deno task dev:up # 起开发库 postgres:18.6(docker-compose.dev.yml)并迁移到最新;拉代码后也跑一次 -deno task dev # 同时起 server(4390)与 web(5180) +bun install --frozen-lockfile # 按 bun.lock 装 workspace 依赖 +bun run dev:up # 起开发库 postgres:18.6(docker-compose.dev.yml)并迁移到最新;拉代码后也跑一次 +bun run dev # 同时起 server(4390)与 web(5180) ``` -单独起:`deno task dev:server`(`deno run --watch`,默认 `--role all`,API + worker 同进程)、`deno task dev:web`。 +单独起:`bun run dev:server`(`bun --watch`,默认 `--role all`,API + worker 同进程)、`bun run dev:web`。 ## 验证 @@ -32,21 +32,76 @@ curl -H "Authorization: Bearer umt-dev-admin-key" "localhost:4390/api/v1/events? | 命令 | 作用 | |---|---| -| `deno task ci` | typecheck(tsc 7.0.2 + deno check)→ lint(biome)→ test(vitest)→ check:tokens → build,提交前必须全绿 | -| `deno task typecheck` | `deno types` 生成 `types/deno.d.ts` 后,逐个成员跑 tsc 7.0.2,再跑 `deno check`(Deno 内置 TS 6.0.3) | -| `deno task lint` / `deno task fmt` | biome 检查 / 自动修复(含 import 排序;相对 import 必须带扩展名) | -| `deno task test` | 逐个成员跑 vitest(`deno run -A npm:vitest run`);单包:`cd packages/ && deno task test` | -| `deno task db:reset` | 清空 public schema 并重新迁移(生产环境拒绝执行) | -| `deno task gen:types` | 从已迁移的库反射生成 `packages/db/src/types.ts` | -| `deno task gen:tokens` | 改了 app.css 后重新生成 token 登记表 | -| `deno task build` | web 构建到 `apps/web/build/client` + server 编译为单文件 `dist/umt-server` | -| `cd apps/server && deno task start` | 以受限权限(net/env/sys/read)直接跑源码 | +| `bun run ci` | typecheck(tsc 7.0.2)→ lint(biome)→ test(vitest)→ check:tokens / check:copy → build → check:binary,提交前必须全绿 | +| `bun run typecheck` | 逐个成员跑 tsc 7.0.2 | +| `bun run lint` / `bun run fmt` | biome 检查 / 自动修复(含 import 排序;相对 import 必须带扩展名) | +| `bun run test` | 逐个成员跑 vitest(`bun x --bun vitest run`);单包:`cd packages/ && bun run test` | +| `bun run db:reset` | 清空 public schema 并重新迁移(生产环境拒绝执行) | +| `bun run gen:types` | 从已迁移的库反射生成 `packages/db/src/types.ts` | +| `bun run gen:tokens` | 改了 app.css 后重新生成 token 登记表 | +| `bun run build` | web 构建到 `apps/web/build/client` + server 编译为单文件 `dist/umt-server` | +| `cd apps/server && bun run start` | 直接跑源码;外部字节解析另走 Linux seccomp 子进程 | -依赖:npm 包版本只在根 `deno.json` 的 `imports` 里登记(web 的前端依赖仍在 `apps/web/package.json`)。改完跑 `deno install` 更新 `deno.lock`。有安装脚本的新依赖需要 `deno approve-scripts` 显式批准。 +依赖:各成员包在 `package.json` 登记依赖,根 `package.json` 管理 workspace 与共用工具。修改依赖后运行 `bun install` 更新 `bun.lock`;正常拉取使用 `bun install --frozen-lockfile`。解析沙箱测试前运行 `bun run sandbox:build`(`test:all` 会自动构建)。 + +## 工程设计与适度抽象 + +适度抽象用于减少重复修改、统一可靠行为,方便后续维护与开发。判断依据是已有实现的共同语义,不只看代码外形相似。 + +- 有稳定的重复机制时及时提取,例如协议分帧、消息上限、进程退出与回收;新增业务任务应能复用这些行为,不必重复写管道循环。 +- 调用方各自决定的策略保持显式。文件操作按进度停滞判断超时,解析按总时长和 RSS 判断;权限、业务重试、取消后的文件清理不放进通信层。 +- 共用机制放在双方可依赖的最小内部模块,按用途导出。不要为了复用创建完整框架,也不要用大量模式开关把不同业务揉成一个类。 +- 保留清晰的类型边界。协议载荷在共用层是 unknown,具体输入、进度和结果由业务模块定义与校验;业务代码不直接依赖传输细节。 +- 只有第二个实际使用方出现相同的管理逻辑时,再提取排队、常驻 worker 管理或进程池。没有性能证据时,不因为别的项目复用了进程就提前改运行方式。 +- 持久业务任务与本地执行任务分别建模。下载、整理等流程的恢复与去重归任务引擎和业务记录;文件/解析 worker 只完成一次执行。通信失败不能自动重做有副作用的操作。 +- 定时任务的触发与执行分开考虑:短巡检可直接运行;长耗时、需要恢复或产生重要副作用的任务应入持久队列。新任务不要在调度器里另建一套重试与恢复逻辑。 +- 抽象后的测试覆盖真实边界:分块传输、异常退出、取消、大小超限、故障回收;业务模块继续验证各自的超时与副作用。不要只测试一层转发调用。 + +当前共用入口为 `@umt/runtime/worker`(`packages/runtime/src/worker.ts`):一次性连接、逐行 JSON 协议及有界回收。文件代理和解析代理分别选择启动环境、消息上限和终止策略;仍使用一次性子进程,未引入常驻池或自动重试。 + +协议为 `{id,task,input}` 请求,以及 `{id,type:"progress",value}` / `{id,type:"result",result}` / `{id,type:"error",error:{code,message,detail?}}` 响应。每条消息必须以换行结束。文件协议单条上限 16 MiB(内容走文件系统),解析协议单条上限 128 MiB(包括 base64);stderr 持续排空且仅保留末尾 2000 字符用于诊断。大小限制不替代任务的解压与资源限制。 + +服务端 `FS_PROXY_MODE` 缺省为 `subprocess`,启动失败报错。`auto` 的进程内回退仅在明确选择时使用,不能保证回收阻塞在系统调用中的线程。解析隔离失败始终报错。 + +### 模块与业务入口 + +- 业务操作由所属模块的 service 提供,负责业务不变量、写入和事务边界。HTTP 路由负责协议、访问权限、参数与响应;能力处理器负责能力输入与授权上下文,然后调用同一业务入口。服务不从路由文件或 Agent 会话编排里取业务实现。 +- 跨模块的同步操作可以显式调用已有 service,无需为一次调用再建接口、注册表或消息总线。需要随业务提交可靠触发的异步后续动作,在同一事务写 outbox;接收方按业务 ID 回查并幂等执行。 +- 共用计划绑定、持续授权范围和预算检查放在 `src/authorization/grants.ts`;Agent 专有的会话、审批交互策略留在 `modules/agent/authorize.ts`。共用数据检查不依赖 Agent 会话装配。 +- 示例:策略预演/发布由 `acquisition-organize/services/policies.ts` 提供,路由与能力共用;整理入口仍是 `services/organize.ts`,内部按计划、执行、收尾拆分。首次完成与恢复重放共用收尾处理,移除 qB 的不同判断留在各自调用处。 +- 任务由所属模块用 `defineTask` 注册真实 handler,`@umt/tasks` 只提供执行机制;未注册任务明确报错,不注册空壳兜底。同步业务规则直接实现,出现实际扩展需求后再判断是否需要钩子。 +- 同一含义的类型只定义一份,例如 domain 与 contracts 共用 `ContentType`。`@umt/mapping` 只处理真实语义差异,例如下载暂停原因或 `undefined ↔ unknown`,不为同值枚举保留转换表。 + +### 状态与配置归属 + +每个阶段维护自己的业务事实,引擎成功只表示本次任务尝试完成。业务仍在等待授权、资源或磁盘空间时,不能因为引擎成功就显示业务已完成。时间线与页面应按这些事实生成只读投影,不要另存一套总流程状态。 + +| 事实 | 维护方 | +|---|---| +| 发布资源解析、身份绑定与证据 | sources-identity 的资源/身份记录 | +| 订阅目标、单元满足度、候选与收录计划 | acquisition-organize 的 subscription / slot / acquisition_plan | +| 下载提交与下载进度 | download_task 与下载服务 | +| 整理计划、执行结果与文件证据 | organization_record / organization_file_result 与整理服务 | +| 刮削结果 | scrape_record 与刮削服务 | +| 步骤、租约、重试与唤醒 | 任务引擎;不替代上述业务状态 | + +部署环境负责连接地址、密钥、基础目录等;settings 负责日常业务策略;单次请求只覆盖明确允许的选项,需要恢复的执行计划冻结本次有效值。读取与校验放在所属模块已有的配置入口,调用方不各写一遍合并规则。存量配置的优先级仍按各模块现有约定执行,有实际重复再统一;对用户有影响的覆盖应说明有效值与来源。 + +收录策略的适度抽象:`domain/acquisition/policy.ts` 维护分类选择顺序,`domain/slot/season-release.ts` 维护规格身份与排序,候选判断仍在 `evaluateCandidate`。`services/collection-strategy.ts` 统一策略模板到订阅版本的创建入口,分类查询在事务外,版本创建在订阅锁内。追更与洗版通过同一 `SlotSpec` 的阶段规则表达,不创建两套计划执行器。季级目标从资产与计划事实计算,计划冻结在途规格;分集完成、界面和旧文件处置都使用这些事实。 + +资源属性属于 sources-identity:`service/release-facts.ts` 合成解析输入,Agent 补充只存 `raw._facts` 并保留出处,重解析不会抹掉它,新的明确解析值优先。`release-facts-admin.ts` 提供窄的补全入口,调查编排和持续授权仍由 agent 模块处理;两者不互相承接业务状态。修改规则编辑器时应保留未编辑的 JSON 字段,同时显式清除用户删除的已编辑字段。 + +后续优先扩展现有纯判断和业务服务;出现共同不变量时抽成有明确输入输出的函数,文件 IO、事务锁、授权和重试继续留在执行阶段。默认策略更新只影响后续模板创建,不隐式改写已建订阅版本;需要调整已有作品时直接编辑其保留版本。新增规则字段必须同时检查候选评估、完成口径、覆盖展示和物理替换,避免各自形成第二套规则。 + +配置收敛先确定编辑归属:订阅表单维护追踪与下载安排,具体版本维护质量、组合规则和洗版;删掉重复入口时同步删除草稿与序列化,不留下两套普通写入路径。旧导入字段保留在兼容入口,普通保存不得清除或重建它们。总览与执行复用 `resolveRuleChain` 的版本优先级,订阅级自动补全继承主版本的收录链;显式开关仍优先。业务记录跳转复用 `subjectLink`,异常状态复用现有决策状态,不另造处理中心。 + +手动下载以默认值直接提交,高级选项仅覆盖本次操作。搜索与工作台复用 `ManualDownloadActions` 的提交反馈、未识别确认和高级选项窗口,资源摘要与 API 参数仍由各入口转换;原站页面保留必须先解析详情的准备步骤。默认下载不先加载目录、作品详情或已有版本,不为三个入口引入通用流程引擎。 + +维护时先验证真实的成功、失败和恢复路径,尤其是授权、文件替换、取消与外部副作用;删除空壳或无使用者的机制时同步删除其专属测试与文档。不要为了目录对称增加转发层,也不要把不同阶段的状态合并成一个枚举。 ## 进程角色 -`umt-server --role api|worker|worker:agent|all`(源码:`deno run ... apps/server/src/main.ts --role ...`)。 +`umt-server --role api|worker|worker:agent|all`(源码:`bun apps/server/src/main.ts --role ...`)。 | 角色 | 引擎 worker | OutboxRelay / 模块后台循环 | |---|---|---| @@ -118,7 +173,7 @@ ci 逐行核对这张表与任务注册表(`apps/server/test/worker-lanes.test.t 各保留期裁剪(`system/retention.ts` 的 `RETENTION_JOBS`)、`metadata.backfill_languages`(元数据语言回刷)。 只入队的定时任务(如 `trash.purge`、`cache.threshold`、`library.reconcile`、`pi.token_refresh`)按所入队任务的道执行。 -启动时 `bootstrap()` 装配 `apps/server/src/modules/` 下的四个业务模块(见 apps/server/README.md),并连接任务引擎。数据库不可达时进程启动失败并退出。 +启动时 `bootstrap()` 装配 `apps/server/src/modules/` 下的业务模块(见 apps/server/README.md),并连接任务引擎。数据库不可达时进程启动失败并退出。 acquisition-organize 的后台循环只做编排:qB 同步(含阈值暂停/恢复)一个定时器;另一个定时器按阈值入队 `cleanupDownloadCache`、 巡检补投 `submitDownload`/`verifyAndImport`、每 6h 入队 `reconcileLibrary`,重活都在任务里跑。 @@ -138,21 +193,21 @@ docker compose up -d --build # 服务:app(4380)+ postgres(数据卷 umt-p curl localhost:4380/api/v1/health ``` -- 镜像:`denoland/deno:2.9.7` 构建 web 与 `deno compile` 出二进制,运行时 `debian:trixie-slim` + gosu + postgresql-client + 静态 ffprobe,root 启动修 `/data` 属主后降权到 PUID(缺省 10001)。 +- 镜像:`oven/bun:1.4.3` 构建 web 与 `bun build --compile` 出二进制,并构建 `sandbox-policy.node`,运行时 `debian:trixie-slim` + gosu + postgresql-client + 静态 ffprobe,root 启动修 `/data` 属主后降权到 PUID(缺省 10001)。 - 入口 `docker/entrypoint.sh`:迁移 → 自检 → 同时起 api、worker、worker:agent 三个进程;任一退出则停掉其余并以非 0 退出,由 `restart: unless-stopped` 拉起。 -- 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,cat`;镜像带静态 ffprobe;运行期写入落在 `DATA_DIR`(容器内 `/data`)。完整部署说明见 [deploy.md](deploy.md)。 +- 主进程使用非 root 用户和只读的 `/app` 产物;外部字节解析另起子进程,由原生 seccomp 策略限制文件、网络与进程访问。镜像带静态 ffprobe;运行期写入落在 `DATA_DIR`(容器内 `/data`)。完整部署说明见 [deploy.md](deploy.md)。 - 构建版本号:`APP_VERSION=v0.1.0 docker compose build`(写入 web 的 `version.json`)。 ## 测试数据库 -需要 PG 的测试(db、tasks)默认连 `DATABASE_URL` 或 55433 开发库,不可达时跳过。tasks 包可用 `TASKS_TEST_DATABASE_URL` 指定独立库;每个测试文件使用带 pid+随机后缀的独立 schema,结束时删除,多份测试并发跑也不冲突。 +需要 PG 的测试默认连 `DATABASE_URL` 或 55433 开发库。单包测试在库不可达时会跳过集成用例,`bun run test:all` 会先检查连接并直接失败,不能把跳过当作通过。tasks 包可用 `TASKS_TEST_DATABASE_URL` 指定独立库;每个测试文件使用带 pid+随机后缀的独立 schema,结束时删除,多份测试并发跑也不冲突。 测试被中断(超时、kill、并发跑挂掉)时 schema 不会删,开发库会越积越多。清理脚本: ```sh -deno run -A scripts/dev-db-clean.ts # dry-run:只打印清单 -deno run -A scripts/dev-db-clean.ts --verbose # 同上,逐个列出 schema 名 -deno run -A scripts/dev-db-clean.ts --yes # 真正删除 +bun scripts/dev-db-clean.ts # dry-run:只打印清单 +bun scripts/dev-db-clean.ts --verbose # 同上,逐个列出 schema 名 +bun scripts/dev-db-clean.ts --yes # 真正删除 ``` - 只在 `DATABASE_URL`(缺省 `postgres://umt:umt@localhost:55433/umt`)指向 localhost:55433 时运行,否则直接拒绝。 @@ -163,6 +218,6 @@ deno run -A scripts/dev-db-clean.ts --yes # 真正删除 - `配置无效`:按提示检查 `.env`,`SECRET_MASTER_KEY` 至少 16 字符,`DATABASE_URL` 必须是 postgres://。 - health 返回 `db: "down"`:`docker compose -f docker-compose.dev.yml ps` 看容器健康状态。 -- `deno check` 报找不到 `Deno`:检查根目录是否误放了 `tsconfig.json`(tsc 基座在 `config/tsconfig.base.json`)。 -- tsc 报 `Deno` 未定义:先跑 `deno task types` 生成 `types/deno.d.ts`(已 gitignore)。 +- 解析沙箱提示策略模块不存在:先跑 `bun run sandbox:build`;编译部署需把 `sandbox-policy.node` 放在二进制旁边。 +- tsc 使用 `config/tsconfig.base.json`;运行时类型来自 Node / Bun,不再生成 Deno 声明。 - 日志中的 passkey/token/cookie/authorization/password 字段与 URL query 参数会被替换为 `[REDACTED]`(含 `logger.child()` 绑定字段与 `err` 的 message/stack);若发现明文,属于缺陷。 diff --git a/docs/runbook/organize.md b/docs/runbook/organize.md index b8e9581b..a5fc21a5 100644 --- a/docs/runbook/organize.md +++ b/docs/runbook/organize.md @@ -1,7 +1,5 @@ # 媒体整理(`/console/organize`) -原地入库为 v0.3.14 的待部署改动;当前生产 v0.3.13 的整理方式仍为复制/移动,独立就地刮削继续可用。 - 整理页只有「整理记录」一页(界面第二轮,2026-10-06)。整理队列学 MP 做成标题栏的「队列 N」按钮(N = 排队 + 执行中,含整理后的刮削,0 不写),点开右侧面板看队列;面板开关在 URL(`?queue=1`),旧链接 `?tab=queue` 打开面板、`?tab=records` 直接去掉。记录显示复制/移动/硬链接/原地整理的结果。文件管理的改名、删除、还原账本继续保存,通用记录接口仍可查询。 口径:本工具只负责「整理」——识别、元数据、NFO 与图片、按规则归到媒体库目录;媒体服务器有没有扫到由它自己决定,界面不写「入库」(见 `docs/design/glossary.md`)。 @@ -82,12 +80,14 @@ ## 手动下载、Local 元数据与一次性洗版(已部署 v0.3.10) -搜索、站点工作台和原站详情均可独立下载,不要求订阅。下载时默认正常入库,也可选择一次性洗版并勾选旧版本;只有一个旧版本时默认选中,多个由用户选择。媒体详情与下载窗口展示已有文件和规格。正常入库保留已有版本;不同版本路径由可编辑的命名模板决定,同名冲突仍需处理。 +搜索、站点工作台和原站详情均可独立下载,不要求订阅。点击下载按站点/默认下载器和保存目录直接提交;修改作品、下载器、目录或一次性洗版从高级下载选项进入,仅本次有效。未识别时先确认,可继续下载后整理识别,也可展开高级选项指定作品。下载时默认正常入库;一次性洗版需勾选旧版本,只有一个旧版本时默认选中,多个由用户选择。媒体详情与高级下载选项展示已有文件和规格。正常入库保留已有版本;不同版本路径由可编辑的命名模板决定,同名冲突仍需处理。 未识别资源确认后允许下载。整理时先匹配 TMDB,再由现有网络/Agent 元数据补全能力核对译名、原名和别名;能够找到正确条目时回到 TMDB,找不到且详情资料充分时使用 Local 身份。Local 共用现有分类、命名、复制/移动和 NFO/图片任务,并保留字段来源;信息不足或候选冲突保留待处理。Agent 搜寻依赖已配置的模型/搜索能力和现有每日补全预算;没有封面资料时不会虚构图片。Local 入库安排元数据文件写入,普通 TMDB 自动刮削保持原设置。 洗版在本次冻结计划中保存选中的旧文件。新文件核验登记成功后才清理所选旧版本,剧集只处理成功对应的季集与 Part;覆盖不完整的合并集保留。相同目标路径会先暂存旧文件,失败后恢复。旧版的私有字幕等随之清理,共享 NFO/图片、未选版本及下载缓存保留;按现有文件替换设置移入回收站或删除。 +订阅自动洗版与一次性替换共用上述执行机制:按当前保留版本与实际计划季集选择旧文件,新文件核验后回收;合并集可以用同保留版本、同套已核验文件分次补齐,另一分辨率和其他发布组不参与覆盖。收录配置见 [rules.md](rules.md)。 + ## 收尾核验与资产登记 - 目标路径已有位置时:本次是「替换」(向导 / 待处理选替换,或洗版选中的旧版本),或旧位置属于别的作品,退役旧位置(没有别的位置时旧资产退役)并新建资产,大小、校验、版本信息都是新文件的;否则沿用同一资产。沿用的位置之前被对账判为缺失 / 不可用时恢复可用,资产事实换成这次入库的文件。 @@ -186,7 +186,7 @@ MP 对照源:`MoviePilot-Frontend/src/views/reorganize/TransferHistoryView.vue` - 分组只对已加载的记录生效(同 MP 分组模式)。 - 统计的「进行中」= 排队 + 执行中;点它时列表只筛执行中。 -## 原地入库(v0.3.14 待部署) +## 原地入库 文件管理器选择已登记媒体库目录中的视频或目录,进入整理弹窗,默认选择「原地入库 · 保留位置与名称」。识别作品、手动指定电影/剧集、TMDB 和季集的方式与复制/移动相同,生成预览后加入现有整理队列。 diff --git a/docs/runbook/rules.md b/docs/runbook/rules.md index 94ac1ec8..cbaca8ce 100644 --- a/docs/runbook/rules.md +++ b/docs/runbook/rules.md @@ -29,11 +29,27 @@ 3. 层内 `!` > `&` > `|`,可加括号;表达式短路求值。未知条件视为不匹配(只可能出现在迁移前的旧数据里,新写入会被拒)。 4. 版本目标自身的 `hardConditions` / `softPreferences.tiers` 保留,视为链首的匿名组;规则组有层时 pri 以规则组为准,升级比较口径自动切到 pri(对照 MP 洗版 `pri_order > current_priority`)。 -**引用链**(`services/rules.ts resolveRuleChain`):订阅私有组 → 版本目标 `ruleGroupIds`(非空时)|| 订阅 `params.ruleGroupIds` || 订阅默认链 → 版本目标开了升级时再叠加洗版默认链;逐个按 scope 过滤,跳过停用/已删。搜索用 `kind: "search"` 取搜索默认链(给 `release.search` / 手动搜索接入)。 +**引用链**(`services/rules.ts resolveRuleChain`):订阅私有组 → 版本目标 `ruleGroupIds`(非空时)|| 订阅 `params.ruleGroupIds` || 订阅默认链 ;补齐阶段只使用该链。洗版阶段优先使用版本目标 `upgradeThreshold.ruleGroupIds`,未单独指定时继承收录链并追加洗版默认链;逐个按 scope 过滤,跳过停用/已删。搜索用 `kind: "search"` 取搜索默认链(给 `release.search` / 手动搜索接入)。 候选评估记录(artifact `candidate_evaluation`)里:`pri` 为最终 pri;`reasons` 带 `rule_group:@`;`trace` 逐组逐层写明命中/未命中及每个原子的依据,如 `规则组「片源」@r1 第1层 4K:未命中(4K✗ 分辨率 1080p)`、`规则组「片源」@r1 硬排除 REMUX 命中(REMUX✓ 来源 REMUX)`。 +## 分类、保留版本与洗版 + +在「规则 → 收录策略」中沿用现有 movie / tv 二级分类选择适用范围。新订阅按「明确选择 → 分类默认 → 作品类型默认 → 全局默认」选择已发布策略,同层按发布时间选择;未匹配时创建默认版本。分类设置页展示当前关联。策略模板创建订阅自己的保留版本,已建版本的规则独立维护,修改默认模板不会覆盖已有版本。 + +订阅表单只选择新建时的收录策略,质量、组合规则和洗版在订阅详情的「收录版本规则」逐版本调整。顺序与已有订阅的自动补全也统一在详情维护;包含、排除仍作为订阅共同过滤。旧 API 的统一质量预设继续兼容,普通表单保存保留旧值。自动补全选择「跟随主版本规则」时,按主版本实际收录链取最后一个设置了补全开关的组;没有设置则关闭,显式开/关优先。 + +- **单版本**:一个保留版本的允许收录范围选择 1080p、2160p,组合优先级例如 `4K & H265 > 4K & H264 > 1080P & H265 > 1080P & H264`。范围内先收低档,再洗到高档;720p 即使其他条件更好也不进入范围。 +- **双版本**:建立两个保留版本,分别只允许 1080p 和 2160p,各自引用组合规则和洗版设置,例如各自用 `H265 > H264` 让本分辨率的最高档为目标档。第一个为主版本,需要自动收齐第二个时开启订阅的「自动补全其他版本」。统一质量预设不会改写策略模板的分辨率边界。 +- **组合优先级**:复用规则组的 AND / OR / NOT 与有序档位,发布组可使用结构化阶梯或现有自定义条件;编码偏好应写入档位,软偏好用于同档候选排序。多组串联仍取最后一个带档位的组,界面显示引用顺序。 +- **阶段**:追更、补齐允许不同集采用不同版本;开启「整季同组同套」后,洗版向当季选定规格收敛,多个单集可以组成一套。规格包含分辨率、来源、编码、发布组、HDR 和剪辑版,不包含集号或种子 ID。已在途的洗版规格保持冻结,结束后才选择更优的一套;同档对齐不降低来源或分辨率等级。 +- **完成与旧文件**:范围内覆盖齐且选定同套资源达到目标才算洗版完成。新文件核验后,仅回收同保留版本被完整覆盖的旧文件;旧合并集要等同套新单集全部入库,另一分辨率和异组文件不能凑覆盖。处置沿用回收站与替换失败恢复机制。 + +组合条件所依赖的分辨率、编码等未知时,不把未知当作否定条件成立,也不悄悄降到低档。资源属性事项交给现有调查通路:Agent 用 `release.inspect` 查看详情与 MediaInfo,有针对 `release.apply_facts` 的持续授权时只补仍缺失且有出处的分辨率、编码、来源或发布组,服务保存事实与出处并触发原规则重评。站点大小、做种、促销等通过详情刷新补齐。没有授权或证据不足时提供人机协同建议;Agent 不改变允许范围、保留目标或组合排序。 + +手动下载的选择只影响本次计划,沿用独立的下载与一次性替换入口。 + ## 内置关键字 `GET /rules/keywords` 返回全表(含 MP 上游正则/条件原文与我们的判定依据 `basis`)。 @@ -60,7 +76,7 @@ | `POST /rules/parse {ruleString}` | → `{layers, ruleString(规范化,有错为 null), errors[{code, message, position, layer, atom?, suggestions?}]}`;code:`empty` `empty_layer` `unknown_atom` `unsupported_atom` `unbalanced_paren` `unexpected_token` `invalid_char` | | `POST /rules/serialize {layers}` | → `{ruleString}`(原子大写,只加必要括号) | | `GET / PUT /rules/defaults` | `{acquisition[], upgrade[], search[]}`,按数组顺序串联;类型不符 400 | -| `GET/POST /rules`、`GET/PATCH/DELETE /rules/{id}` | 列表筛选 `kind source enabled includeDeleted q`;写入可给 `ruleString`(优先)或 `layers`,`hardExcludes` 元素可以是表达式或单层规则串;PATCH 可带 `expectedRevision`(不匹配 409);同名 409;条件无效 422;DELETE 被版本目标/订阅引用 409 | +| `GET/POST /rules`、`GET/PATCH/DELETE /rules/{id}` | 列表筛选 `kind source enabled includeDeleted q`;写入可给 `ruleString`(优先)或 `layers`,`hardExcludes` 元素可以是表达式或单层规则串;PATCH 可带 `expectedRevision`(不匹配 409);同名 409;条件无效 422;DELETE 被版本目标(含洗版)、订阅、有效策略模板引用 409 | | `GET /rules/{id}/revisions` | 审计回放(含迁移前的 `preference_tag.*`) | | `POST /rules/{id}/preview` | `{limit?, sourceReleaseIds?, titles?[{title, subtitle?}], draft?}`;缺省最近 50 条已识别资源;`draft` 试算未保存的层/硬排除 → `{sampleSize, passed, rejected, priHistogram[{pri,count}], items[{title, passed, pri, level, excludedBy, reasons, trace}]}` | | `GET/POST /rules/atoms`、`GET/PATCH/DELETE /rules/atoms/{id}` | 自定义条件;与内置同名 409;被规则组引用不能删 409 | diff --git a/docs/runbook/subscriptions.md b/docs/runbook/subscriptions.md index 72cf032e..1b6a7ffb 100644 --- a/docs/runbook/subscriptions.md +++ b/docs/runbook/subscriptions.md @@ -64,7 +64,7 @@ TMDB 条目保存时先调 `POST /files/entity-from-candidate` 建档(同一 TMD ## 多版本自动补全 -- 开关 `params.autoFill: inherit | on | off`(缺省 inherit):inherit 取订阅生效规则链(私有组 → `params.ruleGroupIds` 或订阅默认链)上**最后一个设了 `auto_fill` 的组**,都没设为开。总览 `autoFill.source` 标来源(subscription / rule_group / default)。 +- 开关 `params.autoFill: inherit | on | off`(缺省 inherit):inherit 取主版本收录链(私有组 → 主版本 `ruleGroupIds` 或兼容订阅组或默认链)上**最后一个设了 `auto_fill` 的组**,都没设为关;显式 on/off 优先。总览 `autoFill.source` 标来源(subscription / rule_group / default)。 - 预算 `params.fillBudget {unitsPerDay?, bytesPerDay?}`:按订阅、按自然日,只计自动补全计划(`acquisition_plan.evaluation.autoFill = true`,草案/取消/失败/过期不计),同时仍受全局每日预算与缓存卷阈值约束。 - 评估(`evaluateForSlot` 建计划前 `autoFillCheck`): - 主版本、没有订阅的版本目标、只有一个版本目标:照旧; @@ -75,7 +75,11 @@ TMDB 条目保存时先调 `POST /files/entity-from-candidate` 建档(同一 TMD - 手动补全 `POST /subscriptions/{id}/slots/{slotId}/fill {unitKeys?}`:入队 `evaluateAcquisition{acquisitionSlotId, forceFill}`,不受开关与补全预算限制(全局预算/缓存卷照常),`unitKeys` 限定只补这些单元;订阅须在追踪中(active/pending)。 - 定时:不新增任务。`slots.reevaluate`(到期 Slot 重评)覆盖补全与预算延后;`subscriptions.search` 按 `next_search_at` 到期搜索。 -## 编辑表单字段(POST / PATCH `params`) +## 订阅字段与 API 兼容(POST / PATCH `params`) + +界面分工见设计 §3:新建时选择策略,质量、组合规则和洗版统一逐版本编辑。下面的 MP 质量与统一洗版字段仅留作 API 兼容,普通表单保存保留旧值,不重新生成。 + +存量订阅在「共同过滤与搜索词」显示旧质量预设,可明确清除:保存时解除旧预设生成且未手改的条件;已单独调整的版本规则、策略和多版本的分辨率范围保留。 写入时规范化,库里只存规范键(未知键原样保留)。PATCH 的 `params` 仍是整体替换。 @@ -107,7 +111,7 @@ TMDB 条目保存时先调 `POST /files/entity-from-candidate` 建档(同一 TMD - 私有组不在规则页 `GET /rules` 列出;它排在引用链最前,不影响订阅默认链 / 显式规则组的选择。 - 迁移为存量订阅(未取消、params 有 include / exclude)各建一个私有组。评估不再读 params 的 include / exclude(`withSubscriptionFilters` 已删)。 -## 质量预设(MP resolution / quality / effect) +## 质量预设兼容(MP resolution / quality / effect) 保存时写到**该订阅自己的**版本目标(`subscription_id = 订阅`;作品共享的无主版本目标不动): @@ -123,7 +127,7 @@ TMDB 条目保存时先调 `POST /files/entity-from-candidate` 建档(同一 TMD | atmos | 私有组包含 `Atmos` | | 改预设(`applySlotPreset`):新预设给出的字段覆盖;旧预设给过、新预设不再给的字段,只有版本目标上的值仍等于旧预设生成的值时才删(手改过的保留)。有变化 revision+1。 -新建订阅时作品还没有版本目标:收录策略有 `defaultSlots` 按模板建;没有模板但给了预设 → 建一个"默认版本"承载预设;都没有则不建(同以前)。 +新建订阅时收录策略有 `defaultSlots` 按模板创建,否则创建一个默认版本;作品尚未关联时,在关联后创建。旧 API 预设作用于自己的版本,不改模板或多版本已有的分辨率边界。 ## 下载提交与整理去向 diff --git a/package.json b/package.json new file mode 100644 index 00000000..1a7089cf --- /dev/null +++ b/package.json @@ -0,0 +1,89 @@ +{ + "name": "uhd-media-tool", + "version": "0.3.44", + "private": true, + "type": "module", + "packageManager": "bun@1.4.3", + "engines": { + "bun": ">=1.4.3" + }, + "workspaces": [ + "apps/*", + "packages/*" + ], + "dependencies": { + "7z-wasm": "1.2.0", + "@babel/parser": "7.29.9", + "@biomejs/biome": "2.5.15", + "@earendil-works/chord": "1.1.0", + "@earendil-works/pi-ai": "1.1.0", + "@earendil-works/pi-durable": "1.1.0", + "@hono/swagger-ui": "0.6.1", + "@hono/zod-openapi": "1.6.3", + "@modelcontextprotocol/sdk": "1.31.0", + "@simplewebauthn/server": "14.0.3", + "@types/node": "24.19.2", + "@types/nunjucks": "3.2.6", + "@types/pg": "8.23.1", + "@xmldom/xmldom": "0.9.12", + "cheerio": "1.2.0", + "fast-xml-parser": "5.11.2", + "fflate": "0.8.3", + "hash-wasm": "4.12.0", + "hono": "4.13.12", + "jpeg-js": "0.4.4", + "kysely": "0.29.6", + "node-unrar-js": "2.0.2", + "nunjucks": "3.2.4", + "openworkflow": "0.10.1", + "pg": "8.23.1", + "pino": "10.3.1", + "pino-pretty": "13.1.3", + "playwright-core": "1.63.0", + "postgres": "3.4.9", + "reflect-metadata": "0.2.2", + "typebox": "1.3.34", + "typescript": "7.0.2", + "vitest": "5.0.3", + "xpath": "0.0.34", + "yaml": "2.9.1", + "zod": "4.6.5", + "@umt/agent": "workspace:*", + "@umt/domain": "workspace:*", + "@umt/adapters": "workspace:*", + "@umt/runtime": "workspace:*", + "@umt/tasks": "workspace:*", + "@umt/db": "workspace:*", + "@umt/testkit": "workspace:*", + "@umt/mapping": "workspace:*", + "@umt/contracts": "workspace:*" + }, + "devDependencies": { + "@types/bun": "1.4.3" + }, + "scripts": { + "dev": "bun run --parallel --filter @umt/server --filter @umt/web dev", + "dev:server": "cd apps/server && bun run dev", + "dev:web": "cd apps/web && bun run dev", + "db:up": "docker compose -f docker-compose.dev.yml up -d --wait", + "dev:up": "bun run db:up && bun run db:migrate", + "db:migrate": "bun --env-file=.env packages/db/scripts/migrate.ts latest", + "db:reset": "bun --env-file=.env packages/db/scripts/migrate.ts reset", + "gen:types": "bun --env-file=.env packages/db/scripts/gen-types.ts", + "typecheck": "bun run typecheck:tsc", + "typecheck:tsc": "bun run --sequential --filter \"@umt/*\" typecheck", + "lint": "bun x --bun biome check .", + "fmt": "bun x --bun biome check --write .", + "test": "bun run test:all", + "test:all": "bun run sandbox:build && bash scripts/test-all.sh", + "test:web:smoke": "bun apps/web/scripts/smoke.mjs", + "check:tokens": "cd apps/web && bun run check:tokens", + "check:copy": "bun scripts/check-copy-punctuation.mjs --strict", + "build": "cd apps/web && bun run build && cd ../server && bun run compile", + "check:binary": "sh scripts/check-compiled-binary.sh", + "ci": "bun run typecheck && bun run lint && bun run test:all && bun run check:tokens && bun run check:copy && bun run build && bun run check:binary", + "gen:tokens": "cd apps/web && bun run gen:tokens", + "release": "bash scripts/release.sh", + "sandbox:build": "sh scripts/build-sandbox.sh" + } +} diff --git a/packages/adapters/README.md b/packages/adapters/README.md index c84134a4..8b78ddc1 100644 --- a/packages/adapters/README.md +++ b/packages/adapters/README.md @@ -14,4 +14,4 @@ | [`fs`](src/fs/README.md) | `copyWithVerify`、`moveSafe`、`crossDeviceMove`、`checkRoot`、`scanIncremental`、`diskUsage`、`ffprobe`、`FsProxy`;另有子路径导出 `@umt/adapters/fsworker`(`fsWorkerMain`,供 `--role fsworker`) | 校验复制、跨卷移动、库根哨兵、增量扫描;FsProxy 把文件操作放进可杀子进程(挂死 → SIGKILL → TIMEOUT) | | [`browser`](src/browser/README.md) | `BrowserProvider` 合同、`createNoneBrowserProvider`、camofox 适配骨架、`redactBrowserUrl` | 浏览器服务接口(M1 只有接口与骨架) | -测试:`deno task test`(vitest,外部服务全部用 `@umt/testkit` 的 mock)。 +测试:`bun run test`(vitest,外部服务全部用 `@umt/testkit` 的 mock)。 diff --git a/packages/adapters/deno.json b/packages/adapters/deno.json deleted file mode 100644 index 3634cfa1..00000000 --- a/packages/adapters/deno.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@umt/adapters", - "version": "0.3.44", - "exports": { ".": "./src/index.ts", "./fsworker": "./src/fs/fsworker.ts", "./alist-mock": "./src/alist/mock.ts" }, - "tasks": { - "test": "deno run -A npm:vitest run", - "typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json" - } -} diff --git a/packages/adapters/package.json b/packages/adapters/package.json index 4a1a00d4..837971f6 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -6,5 +6,15 @@ ".": "./src/index.ts", "./fsworker": "./src/fs/fsworker.ts", "./alist-mock": "./src/alist/mock.ts" + }, + "version": "0.3.44", + "scripts": { + "test": "bun x --bun vitest run", + "typecheck": "bun x --bun tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@umt/contracts": "workspace:*", + "@umt/testkit": "workspace:*", + "@umt/runtime": "workspace:*" } } diff --git a/packages/adapters/src/alist/alist.real.test.ts b/packages/adapters/src/alist/alist.real.test.ts index 5e670932..2c61372f 100644 --- a/packages/adapters/src/alist/alist.real.test.ts +++ b/packages/adapters/src/alist/alist.real.test.ts @@ -12,16 +12,16 @@ import { type AlistAuth, AlistClient } from "./client.ts"; // UMT_ALIST_REAL_ROOT2 可选:另一个挂载(驱动)下的可写目录,测跨驱动复制 / 移动(异步任务) // 跑法:docs/runbook/storage-alist.md「真机验收」。 -const URL_ = Deno.env.get("UMT_ALIST_REAL_URL"); -const ROOT = Deno.env.get("UMT_ALIST_REAL_ROOT"); -const ROOT2 = Deno.env.get("UMT_ALIST_REAL_ROOT2"); -const auth: AlistAuth | null = Deno.env.get("UMT_ALIST_REAL_TOKEN") - ? { kind: "token", token: Deno.env.get("UMT_ALIST_REAL_TOKEN")! } - : Deno.env.get("UMT_ALIST_REAL_USER") +const URL_ = process.env.UMT_ALIST_REAL_URL; +const ROOT = process.env.UMT_ALIST_REAL_ROOT; +const ROOT2 = process.env.UMT_ALIST_REAL_ROOT2; +const auth: AlistAuth | null = process.env.UMT_ALIST_REAL_TOKEN + ? { kind: "token", token: process.env.UMT_ALIST_REAL_TOKEN! } + : process.env.UMT_ALIST_REAL_USER ? { kind: "password", - username: Deno.env.get("UMT_ALIST_REAL_USER")!, - password: Deno.env.get("UMT_ALIST_REAL_PASSWORD") ?? "", + username: process.env.UMT_ALIST_REAL_USER!, + password: process.env.UMT_ALIST_REAL_PASSWORD ?? "", } : null; const enabled = !!URL_ && !!ROOT && !!auth; diff --git a/packages/adapters/src/alist/client.test.ts b/packages/adapters/src/alist/client.test.ts index e2f02cb5..04d41324 100644 --- a/packages/adapters/src/alist/client.test.ts +++ b/packages/adapters/src/alist/client.test.ts @@ -388,14 +388,17 @@ describe("上传", () => { it("空闲超时:服务端不读数据时中止", async () => { const mock = createMockAlist({ token: "t" }); - const stuck: typeof fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const stuck: (input: RequestInfo | URL, init?: RequestInit) => Promise = (async ( + input: Request | URL | string, + init?: RequestInit, + ) => { const req = input instanceof Request ? input : new Request(String(input), init); if (new URL(req.url).pathname === "/api/fs/put") return new Promise((_, reject) => init?.signal?.addEventListener("abort", () => reject(init.signal!.reason)), ); return mock.fetch(input, init); - }) as typeof fetch; + }) as (input: RequestInfo | URL, init?: RequestInit) => Promise; const client = new AlistClient({ baseUrl: mock.base, auth: { kind: "token", token: "t" }, @@ -453,14 +456,17 @@ describe("下载", () => { mock.put("/s1/a.mkv", bytes(1000)); const realFetch = mock.fetch; let expired = false; - const f: typeof fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const f: (input: RequestInfo | URL, init?: RequestInit) => Promise = (async ( + input: Request | URL | string, + init?: RequestInit, + ) => { const u = String(input instanceof Request ? input.url : input); if (u.includes("/d/") && !expired) { expired = true; mock.expireSigns(); } return realFetch(input, init); - }) as typeof fetch; + }) as (input: RequestInfo | URL, init?: RequestInit) => Promise; const c2 = new AlistClient({ baseUrl: mock.base, auth: { kind: "token", token: "perm-token" }, fetch: f, qps: 0 }); await c2.download("/s1/a.mkv", join(dir, "a.mkv")); expect((await stat(join(dir, "a.mkv"))).size).toBe(1000); @@ -478,7 +484,10 @@ describe("下载", () => { const mock = createMockAlist({ token: "t" }); mock.put("/s1/a.mkv", "x"); const calls: { url: string; auth: string | null }[] = []; - const f: typeof fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const f: (input: RequestInfo | URL, init?: RequestInit) => Promise = (async ( + input: Request | URL | string, + init?: RequestInit, + ) => { const req = input instanceof Request ? input : new Request(String(input), init); calls.push({ url: req.url, auth: req.headers.get("authorization") }); if (req.url.startsWith("https://cdn.example")) return new Response("x"); @@ -489,7 +498,7 @@ describe("下载", () => { return Response.json(j); } return res; - }) as typeof fetch; + }) as (input: RequestInfo | URL, init?: RequestInit) => Promise; const c = new AlistClient({ baseUrl: mock.base, auth: { kind: "token", token: "t" }, fetch: f, qps: 0 }); await c.download("/s1/a.mkv", join(dir, "a.mkv")); expect(calls.find((x) => x.url.startsWith("https://cdn.example"))!.auth).toBeNull(); diff --git a/packages/adapters/src/alist/client.ts b/packages/adapters/src/alist/client.ts index edcabceb..ccd0cea1 100644 --- a/packages/adapters/src/alist/client.ts +++ b/packages/adapters/src/alist/client.ts @@ -24,7 +24,7 @@ export interface AlistClientOptions { auth: AlistAuth; /** 路径密码(Alist「元信息」里给目录设的密码),list / get 时带上 */ pathPassword?: string | null; - fetch?: typeof fetch; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; /** 普通请求超时(ms),缺省 30 s */ timeoutMs?: number; /** 数据流无进度空闲超时(ms),缺省 120 s */ @@ -203,7 +203,7 @@ function idleWatch(ms: number, outer?: AbortSignal) { export class AlistClient { readonly baseUrl: string; - private readonly fetchFn: typeof fetch; + private readonly fetchFn: (input: RequestInfo | URL, init?: RequestInit) => Promise; private readonly limiter: AlistLimiter; private readonly o: Required< Pick< diff --git a/packages/adapters/src/alist/mock.ts b/packages/adapters/src/alist/mock.ts index 40337843..f97a6ca3 100644 --- a/packages/adapters/src/alist/mock.ts +++ b/packages/adapters/src/alist/mock.ts @@ -3,6 +3,7 @@ // 令牌无效 code 401;copy 一律异步任务;同挂载 move 立即完成、跨挂载 move 是任务;put 覆盖同名;/d 支持 Range 与 sign。 // 顶层目录视为「挂载」(驱动),用于区分同驱动 / 跨驱动。 import { createHash, randomUUID } from "node:crypto"; +import { type NetAddr, serve } from "@umt/runtime"; export interface MockFile { data: Uint8Array; @@ -43,7 +44,7 @@ export interface MockAlist { readonly base: string; setOrigin(origin: string): void; handler: (req: Request) => Promise; - fetch: typeof fetch; + fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; files: Map; dirs: Map; tasks: Map; @@ -432,7 +433,7 @@ export function createMockAlist(opts: MockAlistOptions = {}): MockAlist { // 老版本(noTaskIds):没有任务 id 的复制 / 移动,在之后的任意 get 上推进 if (opts.noTaskIds) for (const t of tasks.values()) advanceTask(t); return handler(req); - }) as typeof fetch, + }) as (input: RequestInfo | URL, init?: RequestInit) => Promise, files, dirs, tasks, @@ -471,13 +472,13 @@ export function createMockAlist(opts: MockAlistOptions = {}): MockAlist { /** 起一个真 HTTP 监听(ffprobe / 下载代理等需要真实 URL 的测试用);返回 base 与关闭函数 */ export function serveMockAlist(mock: MockAlist): { base: string; close: () => Promise } { - const server = Deno.serve({ port: 0, hostname: "127.0.0.1", onListen: () => {} }, (req) => { + const server = serve({ port: 0, hostname: "127.0.0.1", onListen: () => {} }, (req) => { const u = new URL(req.url); return mock .handler(new Request(`${mock.base}${u.pathname}${u.search}`, req)) .catch(() => new Response("connection reset", { status: 502 })); }); - const addr = server.addr as Deno.NetAddr; + const addr = server.addr as NetAddr; const prev = mock.base; const base = `http://127.0.0.1:${addr.port}`; mock.setOrigin(base); diff --git a/packages/adapters/src/browser/camofox-real.test.ts b/packages/adapters/src/browser/camofox-real.test.ts index 9f3601f5..5e879c2d 100644 --- a/packages/adapters/src/browser/camofox-real.test.ts +++ b/packages/adapters/src/browser/camofox-real.test.ts @@ -41,7 +41,7 @@ function realUpstream(handler: (c: Call) => Reply | undefined) { status: r.status ?? 200, headers: { "content-type": "application/json" }, }); - }) as typeof fetch; + }) as (input: RequestInfo | URL, init?: RequestInit) => Promise; return { calls, fetchImpl }; } diff --git a/packages/adapters/src/browser/camofox/client.ts b/packages/adapters/src/browser/camofox/client.ts index 84a4ef33..d6916a05 100644 --- a/packages/adapters/src/browser/camofox/client.ts +++ b/packages/adapters/src/browser/camofox/client.ts @@ -75,7 +75,7 @@ export interface CamofoxConfig { downloadSink?: DownloadSink; /** waitForDownload 轮询间隔 */ downloadPollMs?: number; - fetch?: typeof fetch; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; logger?: BrowserLogger; now?: () => Date; newId?: () => string; @@ -157,7 +157,7 @@ const LOGIN_PATH = /(?:^|\/)(?:login|takelogin|signin|sign_in|auth)(?:\.php)?(?: export class CamofoxBrowserProvider implements BrowserProvider { readonly kind = "camofox" as const; private readonly base: string; - private readonly fetchImpl: typeof fetch; + private readonly fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise; private readonly lock: ProfileLock; private readonly sessions = new Map(); private readonly leases = new Map(); diff --git a/packages/adapters/src/browser/image.ts b/packages/adapters/src/browser/image.ts index 3869ee28..24bc7c46 100644 --- a/packages/adapters/src/browser/image.ts +++ b/packages/adapters/src/browser/image.ts @@ -1,6 +1,6 @@ /** * 截图的解码、缩放与编码(VIS-1):交给视觉模型的截图缩放到不超过 maxWidth(缺省 1280)像素宽, - * 证据缩略图更小。PNG 自己解码 / 编码(node:zlib),JPEG 用 jpeg-js(纯 JS,无原生依赖,deno compile 可嵌入)。 + * 证据缩略图更小。PNG 自己解码 / 编码(node:zlib),JPEG 用 jpeg-js(纯 JS,无原生依赖,bun build --compile 可嵌入)。 * 只支持浏览器截图实际会出现的格式:8 位 PNG(灰度 / RGB / 调色板 / 带 alpha,非隔行)与基线 JPEG。 */ import { deflateSync, inflateSync } from "node:zlib"; diff --git a/packages/adapters/src/emby/client.ts b/packages/adapters/src/emby/client.ts index 61623611..9075cf8d 100644 --- a/packages/adapters/src/emby/client.ts +++ b/packages/adapters/src/emby/client.ts @@ -26,7 +26,7 @@ export interface EmbyConfig { retryBaseMs?: number; /** 搜索/分页单页大小,默认 50 */ pageSize?: number; - fetch?: typeof fetch; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; } export type Observed = @@ -240,7 +240,7 @@ export class EmbyClient { readonly instanceId: string; private readonly base: string; private readonly flavor: "emby" | "jellyfin"; - private readonly fetchImpl: typeof fetch; + private readonly fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise; private userId: string | undefined; constructor(private readonly cfg: EmbyConfig) { diff --git a/packages/adapters/src/fs/README.md b/packages/adapters/src/fs/README.md index 64e27315..896e558c 100644 --- a/packages/adapters/src/fs/README.md +++ b/packages/adapters/src/fs/README.md @@ -4,13 +4,13 @@ ## 挂死隔离:`FsProxy`(对标 MP fsproxy) -`new FsProxy({ hangTimeoutMs, quickTimeoutMs, progressIntervalMs, killGraceMs, mode })` 把单个文件操作(copy / crossMove / move / match / sha256 / stat / checkRoot / sweep / unlink)放进一次性执行子进程 `fsworker.ts`(`deno run --no-config -A`,约 50 ms 启动),stdin 一条 JSON 请求,stdout 逐行心跳 + 结果。 +`new FsProxy({ hangTimeoutMs, quickTimeoutMs, progressIntervalMs, killGraceMs, mode })` 把单个文件操作(copy / crossMove / move / match / sha256 / stat / checkRoot / sweep / unlink)放进一次性执行子进程 `fsworker.ts`(`bun fsworker.ts`),通过 `@umt/runtime/worker` 发送带 id 的逐行 JSON 请求、进度与结果,单条消息上限 16 MiB。 - 数据类操作(复制、跨卷搬运、哈希)按**进度停滞**判挂死:`hangTimeoutMs`(缺省 120 s)内字节数与阶段都没前进 → SIGTERM(worker 中止并清掉自己的 partial)→ `killGraceMs` 后 SIGKILL → `FsError('TIMEOUT')`。大文件复制几小时不会误杀。 - 快操作按总时长 `quickTimeoutMs`(缺省 30 s)。`statSafe` 超时返回 `{kind:"error",code:"TIMEOUT"}`,`checkRoot` 超时返回 `offline(timeout)`——**挂死不等于文件缺失**。 - 卡在 D 状态的子进程被 `unref`,不拖住本进程;被 SIGKILL 的 partial 由下次写前清理收走。 - 子进程里 sha256 走流式(带心跳),不走 `sha256sum`(看不到进度);吞吐见 `docs/porting/moviepilot-rust-transfer.md` 基准记录。 -- `mode: "auto"`:`Deno.execPath()` 是 deno CLI → `deno run -A fsworker.ts`;否则视为 `deno compile` 出的 umt-server 二进制 → ` --role fsworker`(`apps/server/src/main.ts` 在加载其余模块前分派,启动约 150 ms)。编译产物的 `--allow-run` 必须含自身绝对路径(镜像内 `/app/umt-server`,见 `apps/server/deno.json`);起不来(NotCapable/ENOENT)自动回退 in-process(同样的看门狗,超时立即报 TIMEOUT 并中止,但卡死系统调用占住的线程无法回收),`stats().fallbackReason` 记原因。`stats().workerKind` = script / self / custom。 +- 服务端缺省 `mode: "subprocess"`,启动失败直接报错;显式选择 `mode: "auto"`:`process.execPath` 是 bun CLI → `bun fsworker.ts`;否则视为 `bun build --compile` 出的 umt-server 二进制 → ` --role fsworker`(`apps/server/src/main.ts` 在加载其余模块前分派)。子进程起不来(ENOENT/EACCES)自动回退 in-process(同样的看门狗,超时立即报 TIMEOUT 并中止,但卡死系统调用占住的线程无法回收),`stats().fallbackReason` 记原因。`stats().workerKind` = script / self / custom。 - 容器验收开关:worker 进程设 `UMT_FS_TEST_HANG_AFTER_BYTES=` 后,每个数据类操作的 worker 读到 n 字节即卡死 JS 线程并屏蔽 SIGTERM,用来在生产二进制里验证 停滞超时 → SIGKILL → TIMEOUT;生产勿设。 - 测试开关 `_test: { hangAfterBytes, ignoreTerm }`:worker 在累计进度达到阈值后阻塞 JS 线程,模拟卡死的系统调用。 diff --git a/packages/adapters/src/fs/copy.ts b/packages/adapters/src/fs/copy.ts index 7dbfdc2c..055a6d85 100644 --- a/packages/adapters/src/fs/copy.ts +++ b/packages/adapters/src/fs/copy.ts @@ -6,6 +6,7 @@ import { link, lstat, mkdir, open, readdir, rename, stat, unlink, utimes } from import { basename, dirname, join } from "node:path"; import { Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; +import { Command } from "@umt/runtime"; import { errnoOf, FsError, toFsError } from "./errors.ts"; import { assertLocalPaths } from "./remote-guard.ts"; @@ -133,23 +134,10 @@ export function sha256Stats(): { counts: Record; subprocessUn return { counts: { ...hashCounters }, subprocessUnavailable: sha256sumUnavailable }; } -type DenoLike = { - Command: new ( - cmd: string, - opts: { args: string[]; stdout: "piped"; stderr: "piped"; signal?: AbortSignal }, - ) => { output(): Promise<{ success: boolean; code: number; stdout: Uint8Array; stderr: Uint8Array }> }; - errors: { NotFound: new () => Error; NotCapable?: new () => Error; PermissionDenied: new () => Error }; -}; - async function sha256Subprocess(p: string, signal?: AbortSignal): Promise { - const D = (globalThis as { Deno?: DenoLike }).Deno; - if (!D?.Command) { - sha256sumUnavailable = "no_deno_command"; - return null; - } - let out: Awaited["output"]>>; + let out: Awaited>; try { - out = await new D.Command("sha256sum", { + out = await new Command("sha256sum", { args: ["-b", "--", p], stdout: "piped", stderr: "piped", @@ -157,9 +145,9 @@ async function sha256Subprocess(p: string, signal?: AbortSignal): Promise { }); describe("FsProxy 启动方式", () => { - it("deno CLI 下缺省 run fsworker.ts;显式 workerCommand 记为 custom", () => { + it("bun CLI 下缺省 run fsworker.ts;显式 workerCommand 记为 custom", () => { const auto = defaultWorkerCommand(); expect(auto?.kind).toBe("script"); expect(auto?.args.at(-1)).toMatch(/fsworker\.ts$/); @@ -148,6 +148,13 @@ describe("FsProxy 启动方式", () => { expect(px.stats()).toMatchObject({ mode: "inprocess", fallbackReason: expect.stringMatching(/^spawn_failed/) }); }); + it("强制子进程时启动失败报错,不在主进程执行文件操作", async () => { + const t = await tree({ "a.mkv": "x" }); + const px = new FsProxy({ mode: "subprocess", workerCommand: { cmd: "/nonexistent/umt-worker", args: [] } }); + expect(await px.statSafe(t.path("a.mkv"))).toMatchObject({ kind: "error", code: "IO" }); + expect(px.stats()).toMatchObject({ mode: "subprocess", fallbackReason: null }); + }); + it("验收开关只在 UMT_FS_TEST_HOOKS=1 时生效(单独误设 HANG_AFTER_BYTES 无效)", () => { const env = (m: Record) => (k: string) => m[k]; expect(fsTestHookFromEnv(env({ UMT_FS_TEST_HANG_AFTER_BYTES: "1024" }))).toBeUndefined(); @@ -162,16 +169,16 @@ describe("FsProxy 启动方式", () => { it("UMT_FS_TEST_HANG_AFTER_BYTES(容器验收开关)→ 数据类操作卡死 → SIGKILL → TIMEOUT;快操作不受影响", async () => { const t = await tree({ "src/a.mkv": 3 * MiB, "dst/": null }); const px = new FsProxy({ mode: "subprocess", ...fast }); - Deno.env.set("UMT_FS_TEST_HANG_AFTER_BYTES", String(MiB)); - Deno.env.set("UMT_FS_TEST_HOOKS", "1"); + process.env.UMT_FS_TEST_HANG_AFTER_BYTES = String(MiB); + process.env.UMT_FS_TEST_HOOKS = "1"; try { expect(await px.statSafe(t.path("src/a.mkv"))).toMatchObject({ kind: "ok" }); const e = await px.copy(t.path("src/a.mkv"), t.path("dst/a.mkv")).catch((x) => x); expect(e.code).toBe("TIMEOUT"); expect(px.stats()).toMatchObject({ timeouts: 1, killed: 1 }); } finally { - Deno.env.delete("UMT_FS_TEST_HANG_AFTER_BYTES"); - Deno.env.delete("UMT_FS_TEST_HOOKS"); + delete process.env.UMT_FS_TEST_HANG_AFTER_BYTES; + delete process.env.UMT_FS_TEST_HOOKS; } }, 30_000); }); diff --git a/packages/adapters/src/fs/fsproxy.ts b/packages/adapters/src/fs/fsproxy.ts index 882483cd..46a475c5 100644 --- a/packages/adapters/src/fs/fsproxy.ts +++ b/packages/adapters/src/fs/fsproxy.ts @@ -5,18 +5,20 @@ // 统一转成 FsError('TIMEOUT')——普通可重试 IO 错误,交给任务层退避重试。卡在 D 状态的子进程被 unref,不拖住本进程。 // stat/checkRoot 超时返回 error/offline(timeout),**不**当成文件缺失(REQ §2.1 原则 4)。 // -// 与 MP 的差异:MP 是常驻 worker + 一把锁串行;我们每个操作一个子进程(~50 ms 启动,入库按文件计可忽略), +// 与 MP 的差异:MP 是常驻 worker + 一把锁串行;我们每个操作一个子进程, // 天然并发、互不拖累,也没有"上一个请求挂死把 worker 占住"的问题。 -// 启动方式:execPath 是 deno CLI(开发/测试)→ `deno run -A fsworker.ts`;否则视为 deno compile 出的 umt-server -// 二进制 → ` --role fsworker`(入口 main.ts 在加载其余模块前分派)。编译产物的 --allow-run 须含自身绝对路径 -// (镜像内 /app/umt-server,见 apps/server/deno.json);起不来(NotCapable 等)回退 in-process: +// 启动方式:execPath 是 bun CLI(开发/测试)→ `bun fsworker.ts`;否则视为 bun build --compile 出的 umt-server +// 二进制 → ` --role fsworker`(入口 main.ts 在加载其余模块前分派)。 +// 子进程无法启动时回退 in-process: // 同样的心跳看门狗,超时立即以 TIMEOUT 失败并中止操作,但卡死的系统调用占住的线程无法回收(见 README)。 import { basename } from "node:path"; import { fileURLToPath } from "node:url"; +import { WorkerConnection, WorkerTransportError } from "@umt/runtime/worker"; import type { CopyReceipt, Sha256Result } from "./copy.ts"; import { FsError, type FsErrorCode } from "./errors.ts"; import { DATA_OPS, + FS_MESSAGE_BYTES, type FsOpRequest, type FsProgress, type FsTestHook, @@ -32,7 +34,7 @@ import type { RootHealth, StatResult } from "./stat.ts"; export type { FsProgress } from "./fsops.ts"; export interface FsProxyConfig { - /** auto:能起 deno 子进程就用子进程,否则 in-process;subprocess 强制子进程(起不来报错) */ + /** auto:能起 bun 子进程就用子进程,否则 in-process;subprocess 强制子进程(起不来报错) */ mode?: "auto" | "subprocess" | "inprocess"; /** 数据类操作:进度停滞多久判挂死(缺省 120 s,同 MP FS_PROXY_STALL_TIMEOUT) */ hangTimeoutMs?: number; @@ -62,15 +64,6 @@ export interface FsProxyStats { fallbackReason: string | null; } -type DenoChild = { - stdin: WritableStream; - stdout: ReadableStream; - stderr: ReadableStream; - status: Promise<{ success: boolean; code: number; signal: string | null }>; - kill(sig?: string): void; - unref(): void; -}; - const WORKER_PATH = (() => { try { const u = new URL("./fsworker.ts", import.meta.url); @@ -80,31 +73,12 @@ const WORKER_PATH = (() => { } })(); -/** 当前运行时能用的缺省 worker 启动命令:deno CLI → run 脚本;编译二进制 → 自身 --role fsworker;都不行 → null */ +/** 当前运行时能用的缺省 worker 启动命令:bun CLI → run 脚本;编译二进制 → 自身 --role fsworker;都不行 → null */ export function defaultWorkerCommand(): { cmd: string; args: string[]; kind: "script" | "self" } | null { - const D = (globalThis as { Deno?: { execPath?: () => string; Command?: unknown } }).Deno; - if (!D?.Command || !D.execPath) return null; - let exe: string; - try { - exe = D.execPath(); - } catch { - return null; - } - if (/^deno(\.exe)?$/.test(basename(exe))) { + const exe = process.execPath; + if (/^bun(\.exe)?$/.test(basename(exe))) { if (!WORKER_PATH) return null; - return { - cmd: exe, - args: [ - "run", - "--no-config", - "--quiet", - "--no-prompt", - // Deno 对 /dev、/proc 下的路径(如 /dev/shm 上的库根)要求 --allow-all;worker 只执行本包固定的操作表 - "--allow-all", - WORKER_PATH, - ], - kind: "script", - }; + return { cmd: exe, args: [WORKER_PATH], kind: "script" }; } return { cmd: exe, args: ["--role", "fsworker"], kind: "self" }; } @@ -122,12 +96,7 @@ export function fsTestHookFromEnv(get: (k: string) => string | undefined): FsTes } function envTestHook(): FsTestHook | undefined { - try { - const env = (globalThis as { Deno?: { env?: { get(k: string): string | undefined } } }).Deno?.env; - return env ? fsTestHookFromEnv((k) => env.get(k)) : undefined; - } catch { - return undefined; - } + return fsTestHookFromEnv((k) => process.env[k]); } const timeoutError = (op: string, why: string, path?: string) => @@ -154,7 +123,7 @@ function pathOf(req: FsOpRequest): string | undefined { export class FsProxy { readonly cfg: Required> & Pick; - /** 子进程怎么起:script = deno run fsworker.ts;self = 编译二进制 --role fsworker;custom = 显式 workerCommand */ + /** 子进程怎么起:script = bun fsworker.ts;self = 编译二进制 --role fsworker;custom = 显式 workerCommand */ readonly workerKind: "script" | "self" | "custom" | null; #mode: "subprocess" | "inprocess"; #forced: boolean; @@ -177,9 +146,9 @@ export class FsProxy { if (mode === "inprocess") this.#mode = "inprocess"; else if (wc) this.#mode = "subprocess"; else { - if (mode === "subprocess") throw new Error("fsproxy: 当前运行时无法启动 deno 子进程,且未提供 workerCommand"); + if (mode === "subprocess") throw new Error("fsproxy: 当前运行时无法启动 bun 子进程,且未提供 workerCommand"); this.#mode = "inprocess"; - this.#fallbackReason = "no_deno_cli"; + this.#fallbackReason = "no_bun_cli"; } } @@ -251,7 +220,8 @@ export class FsProxy { try { return await this.#runSubprocess(req, call); } catch (e) { - if (!(e instanceof SpawnError) || this.#forced) throw e instanceof SpawnError ? e.cause : e; + if (!(e instanceof SpawnError)) throw e; + if (this.#forced) throw new FsError("IO", `文件 worker 无法启动:${e.message}`, { cause: e.cause }); this.#mode = "inprocess"; this.#fallbackReason = e.message; } @@ -310,144 +280,72 @@ export class FsProxy { } } - async #runSubprocess(req: FsOpRequest, call: FsCallOptions): Promise { - const D = (globalThis as unknown as { Deno: DenoLike }).Deno; - const cmd = this.cfg.workerCommand!; - let child: DenoChild; + async #runSubprocess(req: FsOpRequest, call: FsCallOptions = {}): Promise { + let connection: WorkerConnection; try { - child = new D.Command(cmd.cmd, { - args: cmd.args, - stdin: "piped", - stdout: "piped", - stderr: "piped", - env: { UMT_FS_PROGRESS_INTERVAL_MS: String(this.cfg.progressIntervalMs) }, - }).spawn(); - } catch (e) { - throw new SpawnError(`spawn_failed:${(e as Error)?.name ?? "Error"}`, e); + connection = new WorkerConnection(this.cfg.workerCommand!, { + values: { UMT_FS_PROGRESS_INTERVAL_MS: String(this.cfg.progressIntervalMs) }, + }); + } catch (error) { + throw new SpawnError(`spawn_failed:${(error as Error).name}`, error); } this.#stats.spawned += 1; const limit = this.#limitFor(req); - const w = child.stdin.getWriter(); + let last = Date.now(); + let lastKey = ""; + const terminate = (error: FsError) => connection.terminate(error, this.cfg.killGraceMs, () => this.#stats.killed++); + const onAbort = () => terminate(new FsError("ABORTED", "aborted", { path: pathOf(req) })); + const watch = setInterval( + () => { + if (Date.now() - last > limit.ms) { + this.#stats.timeouts += 1; + clearInterval(watch); + terminate(timeoutError(req.op, limit.why, pathOf(req))); + } + }, + Math.max(10, Math.min(1000, limit.ms / 4)), + ); + call.signal?.addEventListener("abort", onAbort, { once: true }); const hook = call._test ?? (DATA_OPS.has(req.op) ? envTestHook() : undefined); - await w.write(new TextEncoder().encode(JSON.stringify(hook ? { ...req, _test: hook } : req))); - await w.close().catch(() => undefined); - - let stderr = ""; - const errPump = (async () => { - const dec = new TextDecoder(); - try { - for await (const c of child.stderr) stderr = (stderr + dec.decode(c, { stream: true })).slice(-2000); - } catch { - // 被 kill 时流会出错 - } - })(); - - return await new Promise((resolve, reject) => { - let settled = false; - let last = Date.now(); - let lastKey = ""; - let killTimer: ReturnType | undefined; - /** 已判超时/已取消:之后 worker 自己报的结果(如 SIGTERM 后的 ABORTED)一律以此为准 */ - let terminating: FsError | null = null; - const finish = (fn: () => void) => { - if (settled) return; - settled = true; - clearInterval(watch); - call.signal?.removeEventListener("abort", onAbort); - fn(); - }; - const terminate = (err: FsError) => { - if (settled || terminating) return; - terminating = err; - try { - child.kill("SIGTERM"); - } catch { - // 已退出 - } - clearInterval(watch); - killTimer = setTimeout(() => { - try { - child.kill("SIGKILL"); - this.#stats.killed += 1; - } catch { - // SIGTERM 后已退出 - } - // D 状态的子进程可能连 SIGKILL 都要等系统调用返回:不等它,unref 后直接报错 - child.unref(); - finish(() => reject(err)); - }, this.cfg.killGraceMs); - }; - const onAbort = () => - terminate(new FsError("ABORTED", "aborted", { ...(pathOf(req) ? { path: pathOf(req) } : {}) })); - call.signal?.addEventListener("abort", onAbort, { once: true }); - const watch = setInterval( - () => { - if (Date.now() - last > limit.ms) { - this.#stats.timeouts += 1; - terminate(timeoutError(req.op, limit.why, pathOf(req))); - } + try { + const pending = connection.request( + { + id: "1", + task: req.op, + input: hook ? { ...req, _test: hook } : req, }, - Math.max(10, Math.min(1000, limit.ms / 4)), - ); - - (async () => { - const dec = new TextDecoder(); - let buf = ""; - let final: WorkerFinal | null = null; - try { - for await (const c of child.stdout) { - buf += dec.decode(c, { stream: true }); - let i = buf.indexOf("\n"); - while (i >= 0) { - const line = buf.slice(0, i); - buf = buf.slice(i + 1); - i = buf.indexOf("\n"); - if (!line.trim()) continue; - const msg = JSON.parse(line) as WorkerFinal & FsProgress; - if (msg.t === "hb") { - const key = `${msg.phase}:${msg.bytes}`; - if (!limit.stall || key !== lastKey) { - lastKey = key; - if (limit.stall) last = Date.now(); - } - call.onProgress?.({ phase: msg.phase, bytes: msg.bytes, total: msg.total }); - } else final = msg; + { + maxRequestBytes: FS_MESSAGE_BYTES, + maxResponseBytes: FS_MESSAGE_BYTES, + onProgress: (value) => { + const p = value as FsProgress; + if (!p || typeof p.phase !== "string" || !Number.isFinite(p.bytes) || !Number.isFinite(p.total)) + throw new Error("文件 worker 进度格式无效"); + const key = `${p.phase}:${p.bytes}`; + if (limit.stall && key !== lastKey) { + lastKey = key; + last = Date.now(); } - } - } catch { - // kill 时 stdout 断开 - } - const status = await child.status; - await errPump; - if (terminating) { - // SIGTERM 生效(worker 清理后退出)就不必等到宽限期满 - clearTimeout(killTimer); - const err = terminating; - return finish(() => reject(err)); - } - if (final?.t === "ok") return finish(() => resolve(final!.result)); - if (final?.t === "err") - return finish(() => - reject( - new FsError(final!.code ?? "IO", final!.message ?? "fs worker error", { - ...(final!.path ? { path: final!.path } : {}), - }), - ), - ); - finish(() => - reject( - new FsError("IO", `fs worker 异常退出(code=${status.code} signal=${status.signal}):${stderr.trim()}`, { - ...(pathOf(req) ? { path: pathOf(req) } : {}), - }), - ), - ); - })(); - }); + call.onProgress?.(p); + }, + }, + ); + if (call.signal?.aborted) onAbort(); + const reply = await pending; + if (reply.type === "result") return reply.result; + throw new FsError(reply.error.code as FsErrorCode, reply.error.message, reply.error.detail); + } catch (error) { + if (error instanceof FsError) throw error; + if (error instanceof WorkerTransportError && error.kind === "spawn") + throw new SpawnError(`spawn_failed:${(error.cause as Error)?.name ?? "Error"}`, error.cause); + throw new FsError("IO", (error as Error).message, { path: pathOf(req), cause: error }); + } finally { + clearInterval(watch); + call.signal?.removeEventListener("abort", onAbort); + } } } -type WorkerFinal = { t: string; result?: unknown; code?: FsErrorCode; message?: string; path?: string }; - class SpawnError extends Error { constructor( message: string, @@ -457,20 +355,6 @@ class SpawnError extends Error { } } -type DenoLike = { - execPath(): string; - Command: new ( - cmd: string, - opts: { - args: string[]; - stdin: "piped"; - stdout: "piped"; - stderr: "piped"; - env?: Record; - }, - ) => { spawn(): DenoChild }; -}; - let shared: FsProxy | null = null; /** 进程级缺省代理(配置由 server 启动时 configureFsProxy 注入;未配置时用缺省阈值) */ export function fsProxy(): FsProxy { diff --git a/packages/adapters/src/fs/fsworker.ts b/packages/adapters/src/fs/fsworker.ts index b6e9017d..f2e5e262 100644 --- a/packages/adapters/src/fs/fsworker.ts +++ b/packages/adapters/src/fs/fsworker.ts @@ -1,34 +1,25 @@ -// fsproxy 执行子进程:从 stdin 读一条 JSON 请求,执行单个文件操作,stdout 逐行 JSON 上报 -// {t:"hb",phase,bytes,total}(按 progressIntervalMs 节流,阶段切换与阶段完成立即报)/ {t:"ok",result} / {t:"err",code,message}。 -// SIGTERM → 中止当前操作(copy 会清掉自己的 partial)后退出;挂死在系统调用里时由父进程 SIGKILL 回收。 -// 只依赖 node 内置模块,`deno run --no-config` 毫秒级启动。编译二进制可在入口按 --role fsworker 调 fsWorkerMain()。 +import { decodeWorkerRequest, readWorkerRequestText, writeWorkerMessage } from "@umt/runtime/worker"; import { toFsError } from "./errors.ts"; -import { type FsOpRequest, type FsProgress, runFsOp } from "./fsops.ts"; - -const enc = new TextEncoder(); - -function send(obj: unknown): void { - try { - Deno.stdout.writeSync(enc.encode(`${JSON.stringify(obj)}\n`)); - } catch { - // 父进程已退出:无处可报 - } -} +import { FS_MESSAGE_BYTES, type FsOpRequest, type FsProgress, runFsOp } from "./fsops.ts"; +// 一次性文件 worker;固定操作分派、进度停滞策略与文件清理语义保留在文件模块。 /** 阻塞 JS 线程(测试用,模拟卡死的系统调用:信号监听器也不会运行) */ function blockForever(): never { const a = new Int32Array(new SharedArrayBuffer(4)); for (;;) Atomics.wait(a, 0, 0); } -export async function fsWorkerMain(progressIntervalMs = Number(Deno.env.get("UMT_FS_PROGRESS_INTERVAL_MS") ?? 1000)) { - const req = JSON.parse(await new Response(Deno.stdin.readable).text()) as FsOpRequest; +export async function fsWorkerMain(progressIntervalMs = Number(process.env.UMT_FS_PROGRESS_INTERVAL_MS ?? 1000)) { + const request = decodeWorkerRequest(await readWorkerRequestText(FS_MESSAGE_BYTES)); + const req = request.input as FsOpRequest; + if (!req || req.op !== request.task) throw new Error("文件 worker 任务与操作不一致"); + const send = (value: FsProgress) => writeWorkerMessage({ id: request.id, type: "progress", value }, FS_MESSAGE_BYTES); const ac = new AbortController(); - Deno.addSignalListener("SIGTERM", () => { + process.on("SIGTERM", () => { if (req._test?.ignoreTerm) return; ac.abort(); // 操作卡在系统调用里时 abort 解不开:给清理一点时间后直接退出 - setTimeout(() => Deno.exit(143), 500); + setTimeout(() => process.exit(143), 500); }); let lastAt = 0; let lastPhase = ""; @@ -45,28 +36,29 @@ export async function fsWorkerMain(progressIntervalMs = Number(Deno.env.get("UMT ) { lastAt = now; lastPhase = p.phase; - send({ t: "hb", ...p }); + send(p); } if (hang !== undefined && p.bytes >= hang) blockForever(); }; if (req._test?.hangAfterBytes === 0) { - send({ t: "hb", phase: "start", bytes: 0, total: 0 }); + send({ phase: "start", bytes: 0, total: 0 }); blockForever(); } try { const result = await runFsOp(req, { signal: ac.signal, progress }); - send({ t: "ok", result }); + writeWorkerMessage({ id: request.id, type: "result", result }, FS_MESSAGE_BYTES); } catch (e) { const fe = toFsError(e); - send({ - t: "err", - code: ac.signal.aborted ? "ABORTED" : fe.code, - message: fe.message, - ...(fe.detail.path ? { path: fe.detail.path } : {}), - ...(fe.detail.syscall ? { syscall: fe.detail.syscall } : {}), - }); + writeWorkerMessage( + { + id: request.id, + type: "error", + error: { code: ac.signal.aborted ? "ABORTED" : fe.code, message: fe.message, detail: fe.detail }, + }, + FS_MESSAGE_BYTES, + ); } - Deno.exit(0); + process.exit(0); } if (import.meta.main) await fsWorkerMain(); diff --git a/packages/adapters/src/meta/index.ts b/packages/adapters/src/meta/index.ts index 10d61dc0..0453453c 100644 --- a/packages/adapters/src/meta/index.ts +++ b/packages/adapters/src/meta/index.ts @@ -44,7 +44,7 @@ export interface MetaRequestCache { } export interface MetaClientOptions { - fetch?: typeof globalThis.fetch; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; /** 缺省 15s */ timeoutMs?: number; /** API 地址(bgm api.bgm.tv / 豆瓣 rexxar) */ diff --git a/packages/adapters/src/qbittorrent/client.ts b/packages/adapters/src/qbittorrent/client.ts index 223b362b..e034e09b 100644 --- a/packages/adapters/src/qbittorrent/client.ts +++ b/packages/adapters/src/qbittorrent/client.ts @@ -39,7 +39,7 @@ export interface QbInstanceConfig { sequentialDownload?: boolean; firstLastPiecePrio?: boolean; /** 注入 fetch(测试/代理) */ - fetch?: typeof fetch; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; } export type QbAuthMode = "password" | "api_key" | "none"; @@ -314,7 +314,7 @@ export class QbClient implements DownloaderClient { private readonly retryBaseMs: number; private readonly lookup: { attempts: number; intervalMs: number }; private readonly probePrefix: string; - private readonly fetchImpl: typeof fetch; + private readonly fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise; private sid: string | null = null; private loginInFlight: Promise | null = null; /** qB 5.x 把 pause/resume 改名为 stop/start;探测到后缓存 */ diff --git a/packages/adapters/src/rss/fetch.ts b/packages/adapters/src/rss/fetch.ts index 658c4bc0..23340b17 100644 --- a/packages/adapters/src/rss/fetch.ts +++ b/packages/adapters/src/rss/fetch.ts @@ -7,7 +7,7 @@ import { redactText, redactUrl } from "./redact.ts"; import { FeedError, type FeedFetchResult, type FeedFetchState } from "./types.ts"; export interface RssFetcherOptions { - fetch?: typeof globalThis.fetch; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; now?: () => Date; /** 单次请求超时,默认 30s(MP `timeout or 30`) */ timeoutMs?: number; @@ -31,7 +31,7 @@ export interface FetchFeedOptions { /** 本次请求的 UA(覆盖 RssFetcherOptions.userAgent;BR-4 按站点账号取) */ userAgent?: string; /** 本次请求用的 fetch(覆盖 RssFetcherOptions.fetch;BR-4 站点代理 = 与站点其它请求同一出口) */ - fetch?: typeof globalThis.fetch; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; } export function initialFeedState(): FeedFetchState { diff --git a/packages/adapters/src/sites/challenge.test.ts b/packages/adapters/src/sites/challenge.test.ts index 364435f3..5be8014a 100644 --- a/packages/adapters/src/sites/challenge.test.ts +++ b/packages/adapters/src/sites/challenge.test.ts @@ -22,7 +22,7 @@ const CF_JS_PAGE = `Just a moment... `; const NORMAL = `种子 - 观众ok`; -function ctx(fetchImpl: typeof fetch) { +function ctx(fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise) { return { siteId: "s1", fetch: fetchImpl, @@ -32,7 +32,10 @@ function ctx(fetchImpl: typeof fetch) { }; } const reply = (status: number, body: string, headers: Record = {}) => - (async () => new Response(body, { status, headers: { "content-type": "text/html", ...headers } })) as typeof fetch; + (async () => new Response(body, { status, headers: { "content-type": "text/html", ...headers } })) as ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise; describe("challengeRuleOfHtml", () => { it("挑战页标题 / 脚本特征命中;正常页面里注入的 challenge-platform 脚本不算", () => { @@ -62,7 +65,7 @@ describe("detectChallenge", () => { describe("siteFetch 挑战分类", () => { const url = "https://audiences.example/torrents.php"; - const kind = async (f: typeof fetch) => { + const kind = async (f: (input: RequestInfo | URL, init?: RequestInit) => Promise) => { try { await siteFetch(ctx(f), url); return "ok"; diff --git a/packages/adapters/src/sites/challenge.ts b/packages/adapters/src/sites/challenge.ts index 5c213986..a9027833 100644 --- a/packages/adapters/src/sites/challenge.ts +++ b/packages/adapters/src/sites/challenge.ts @@ -130,7 +130,7 @@ async function peekBody( res: Response, max: number, ): Promise<{ head: Uint8Array; complete: boolean; res: Response }> { - const reader = (res.body as ReadableStream).getReader(); + const reader = (res.body as unknown as ReadableStream).getReader(); const chunks: Uint8Array[] = []; let total = 0; let complete = false; diff --git a/packages/adapters/src/sites/deps.ts b/packages/adapters/src/sites/deps.ts index b9e42f08..bf7e8246 100644 --- a/packages/adapters/src/sites/deps.ts +++ b/packages/adapters/src/sites/deps.ts @@ -5,7 +5,7 @@ import type { SecretResolver } from "./types.ts"; export interface SiteAdapterDeps { secrets: SecretResolver; - fetch?: typeof globalThis.fetch; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; /** 同站共享的限流器;不传则按站点定义新建(列表/搜索/详情/下载共享) */ limiter?: SiteRateLimiter; now?: () => Date; diff --git a/packages/adapters/src/sites/http.ts b/packages/adapters/src/sites/http.ts index bd5b031e..d9272a15 100644 --- a/packages/adapters/src/sites/http.ts +++ b/packages/adapters/src/sites/http.ts @@ -6,7 +6,7 @@ import { SiteError } from "./types.ts"; export interface SiteHttpContext { siteId: string; - fetch: typeof globalThis.fetch; + fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; limiter: SiteRateLimiter; timeoutMs: number; userAgent?: string; diff --git a/packages/adapters/src/sites/login/nexusphp.ts b/packages/adapters/src/sites/login/nexusphp.ts index 99143939..7771a2a7 100644 --- a/packages/adapters/src/sites/login/nexusphp.ts +++ b/packages/adapters/src/sites/login/nexusphp.ts @@ -53,7 +53,7 @@ export interface NexusLoginInput { /** base32 TOTP 密钥 */ totpSecret?: string | null; userAgent: string; - fetch?: typeof globalThis.fetch; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; limiter?: { acquire(signal?: AbortSignal): Promise }; signal?: AbortSignal; timeoutMs?: number; diff --git a/packages/adapters/src/sites/spider/challenge-page.test.ts b/packages/adapters/src/sites/spider/challenge-page.test.ts index 039fc771..a9e656cb 100644 --- a/packages/adapters/src/sites/spider/challenge-page.test.ts +++ b/packages/adapters/src/sites/spider/challenge-page.test.ts @@ -38,7 +38,7 @@ function adapter(body: string, status = 200, headers: Record = { new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...headers }, - })) as typeof fetch; + })) as (input: RequestInfo | URL, init?: RequestInit) => Promise; const a = createSpiderSiteAdapter(audiencesDefinition, { baseUrl: "https://aud.test/", secrets: staticSecretResolver({ [audiencesDefinition.id]: { cookie: "c_secure_uid=1; c_secure_pass=abc" } }), diff --git a/packages/adapters/src/sites/spider/detail-page.test.ts b/packages/adapters/src/sites/spider/detail-page.test.ts index 05306475..515dec79 100644 --- a/packages/adapters/src/sites/spider/detail-page.test.ts +++ b/packages/adapters/src/sites/spider/detail-page.test.ts @@ -176,7 +176,7 @@ describe("详情页扩展内容(憨憨 2026 真实结构:分节 div,无 #kdescr) paths.push(`${u.pathname}${u.search}`); const body = u.pathname === "/viewfilelist.php" ? HHANCLUB_FILELIST_FRAGMENT : HHANCLUB_DETAIL_PAGE_2026; return new Response(body, { status: 200, headers: { "content-type": "text/html; charset=utf-8" } }); - }) as typeof globalThis.fetch; + }) as (input: RequestInfo | URL, init?: RequestInit) => Promise; const limiter = createSiteRateLimiter({ minIntervalMs: 0 }); const a = createSpiderSiteAdapter(hhanclubDefinition, { fetch, diff --git a/packages/adapters/src/sites/spiders/testing.ts b/packages/adapters/src/sites/spiders/testing.ts index 97d1b2dc..d0261314 100644 --- a/packages/adapters/src/sites/spiders/testing.ts +++ b/packages/adapters/src/sites/spiders/testing.ts @@ -30,7 +30,7 @@ export function testDef( export function adapterWith( def: SpecializedSiteDefinition, spider: SpecializedSpider, - fetchImpl: typeof globalThis.fetch, + fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise, secrets: Partial> = { cookie: "c=COOKIESECRET", apiKey: "APIKEYSECRET" }, ): SiteAdapter { return createSpecializedSiteAdapter(def, spider, { @@ -42,10 +42,14 @@ export function adapterWith( /** 包一层 fetch:命中 predicate 的请求返回合法 .torrent 字节 */ export function withTorrent( - inner: typeof globalThis.fetch, + inner: (input: RequestInfo | URL, init?: RequestInit) => Promise, predicate: (url: URL) => boolean, name = "demo", -): { fetch: typeof globalThis.fetch; infoHash: string; hits: Array<{ url: URL; headers: Headers }> } { +): { + fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; + infoHash: string; + hits: Array<{ url: URL; headers: Headers }>; +} { const t = buildFakeTorrent({ name }); const hits: Array<{ url: URL; headers: Headers }> = []; const f = (async (input: string | URL | Request, init?: RequestInit) => { @@ -58,7 +62,7 @@ export function withTorrent( }); } return inner(input, init); - }) as typeof globalThis.fetch; + }) as (input: RequestInfo | URL, init?: RequestInit) => Promise; return { fetch: f, infoHash: t.infoHash, hits }; } diff --git a/packages/adapters/src/sites/user-parsers/testing.ts b/packages/adapters/src/sites/user-parsers/testing.ts index 09646e94..619f5064 100644 --- a/packages/adapters/src/sites/user-parsers/testing.ts +++ b/packages/adapters/src/sites/user-parsers/testing.ts @@ -4,7 +4,7 @@ import type { SiteUserInfo, SiteUserParserOptions } from "./types.ts"; export async function runParser( cls: SiteParserClass, - opts: Partial & { fetch: typeof globalThis.fetch }, + opts: Partial & { fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise }, ): Promise { const p = new cls({ siteName: "demo", url: "https://pt.example.com/", ...opts }); await p.parse(); diff --git a/packages/adapters/src/sites/user-parsers/types.ts b/packages/adapters/src/sites/user-parsers/types.ts index 2a2fc1f9..6e0e55f3 100644 --- a/packages/adapters/src/sites/user-parsers/types.ts +++ b/packages/adapters/src/sites/user-parsers/types.ts @@ -92,7 +92,7 @@ export interface SiteUserParserOptions { apiUrl?: string | null; /** 是否读取未读消息正文(MP 全局设置 `SITE_MESSAGE`);默认 false。注意 MTorrent 读取后会标记已读 */ fetchMessages?: boolean; - fetch?: typeof globalThis.fetch; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; /** 单请求超时(MP 固定 60s) */ timeoutMs?: number; /** 共享站点限流器(与 list/search 共用) */ diff --git a/packages/adapters/src/tmdb/client.ts b/packages/adapters/src/tmdb/client.ts index 1a2b2f47..ce9b04a0 100644 --- a/packages/adapters/src/tmdb/client.ts +++ b/packages/adapters/src/tmdb/client.ts @@ -119,7 +119,7 @@ export interface TmdbConfig { * 语义对照 MP @cached(skip_none、业务失败不缓存、results 为空按 emptyTtlMs)。 */ sharedCache?: TmdbSharedCache; - fetch?: typeof fetch; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; now?: () => number; } @@ -185,7 +185,7 @@ export class TmdbClient { private readonly langs: string[]; private readonly cache: TtlLruCache; private readonly limiter: RequestLimiter; - private readonly fetchImpl: typeof fetch; + private readonly fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise; constructor(private readonly cfg: TmdbConfig) { if (!cfg.apiKey && !cfg.accessToken) throw new TypeError("TMDB apiKey or accessToken required"); diff --git a/packages/adapters/src/tmdb/tmdb.test.ts b/packages/adapters/src/tmdb/tmdb.test.ts index a976843a..b6036a56 100644 --- a/packages/adapters/src/tmdb/tmdb.test.ts +++ b/packages/adapters/src/tmdb/tmdb.test.ts @@ -219,7 +219,7 @@ describe("TmdbClient 缓存/限流/错误分类", () => { const e1 = await dead.details("movie", 1).catch((x) => x); expect(e1.kind).toBe("network"); // 模拟把 URL(含 api_key 参数与编码后的明文)拼进信息的 fetch 实现 - const leaky: typeof fetch = async (input) => { + const leaky: (input: RequestInfo | URL, init?: RequestInit) => Promise = async (input) => { throw new TypeError(`error sending request for url (${String(input)}): key ${encodeURIComponent(KEY)}`); }; const e2 = await createTmdbClient({ baseUrl: "http://tmdb.local/3", apiKey: KEY, retries: 0, fetch: leaky }) diff --git a/packages/adapters/src/websearch/brave.ts b/packages/adapters/src/websearch/brave.ts index f38a9eaa..0814bc5e 100644 --- a/packages/adapters/src/websearch/brave.ts +++ b/packages/adapters/src/websearch/brave.ts @@ -16,7 +16,7 @@ export interface BraveClientOptions { baseUrl?: string; /** 缺省 10s */ timeoutMs?: number; - fetch?: typeof fetch; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; } export interface BraveSearchParams { diff --git a/packages/adapters/src/websearch/page.ts b/packages/adapters/src/websearch/page.ts index f2ba0c38..a89ab93f 100644 --- a/packages/adapters/src/websearch/page.ts +++ b/packages/adapters/src/websearch/page.ts @@ -18,7 +18,7 @@ export interface FetchPageOptions { /** 最多跟随几次重定向,缺省 3 */ maxRedirects?: number; userAgent?: string; - fetch?: typeof fetch; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; resolve?: HostResolver; signal?: AbortSignal; } diff --git a/packages/adapters/src/websearch/websearch.test.ts b/packages/adapters/src/websearch/websearch.test.ts index 8007dc28..a853f2ea 100644 --- a/packages/adapters/src/websearch/websearch.test.ts +++ b/packages/adapters/src/websearch/websearch.test.ts @@ -41,7 +41,7 @@ describe("Brave 客户端", () => { fetch: (async (url: string, init: RequestInit) => { seen = { url, headers: new Headers(init.headers) }; return jsonRes(BRAVE_BODY); - }) as typeof fetch, + }) as (input: RequestInfo | URL, init?: RequestInit) => Promise, }); const r = await c.search({ q: "MoviePilot v3", @@ -85,8 +85,10 @@ describe("Brave 客户端", () => { ])("HTTP %i → %s,错误信息不含 Key", async (status, kind) => { const c = createBraveClient({ apiKey: KEY, - fetch: (async () => - jsonRes({ error: `bad token ${KEY}` }, status, { "x-ratelimit-reset": "1, 1000" })) as typeof fetch, + fetch: (async () => jsonRes({ error: `bad token ${KEY}` }, status, { "x-ratelimit-reset": "1, 1000" })) as ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise, }); const err = (await c.search({ q: "x", count: 1 }).catch((e) => e)) as WebSearchError; expect(err).toBeInstanceOf(WebSearchError); @@ -100,7 +102,7 @@ describe("Brave 客户端", () => { apiKey: KEY, fetch: (async () => { throw new TypeError(`connect fail ${KEY}`); - }) as typeof fetch, + }) as (input: RequestInfo | URL, init?: RequestInit) => Promise, }); const e1 = (await net.search({ q: "x", count: 1 }).catch((e) => e)) as WebSearchError; expect(e1.kind).toBe("network"); @@ -109,9 +111,10 @@ describe("Brave 客户端", () => { apiKey: KEY, timeoutMs: 50, fetch: ((_u: string, init: RequestInit) => - new Promise((_r, rej) => - init.signal?.addEventListener("abort", () => rej(new Error("aborted"))), - )) as typeof fetch, + new Promise((_r, rej) => init.signal?.addEventListener("abort", () => rej(new Error("aborted"))))) as ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise, }); expect(((await slow.search({ q: "x", count: 1 }).catch((e) => e)) as WebSearchError).kind).toBe("timeout"); }); @@ -164,8 +167,10 @@ describe("读取网页", () => { it("HTML 转纯文本:去脚本样式表单,保留标题与段落,URL 凭证脱敏", async () => { const p = await fetchPage("https://example.org/notice", { resolve: publicDns, - fetch: (async () => - new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } })) as typeof fetch, + fetch: (async () => new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } })) as ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise, }); expect(p.title).toBe("站点公告"); expect(p.text).toContain("9 月 20 日维护。"); @@ -180,7 +185,10 @@ describe("读取网页", () => { const big = "a".repeat(600 * 1024); const p = await fetchPage("https://example.org/big.txt", { resolve: publicDns, - fetch: (async () => new Response(big, { headers: { "content-type": "text/plain" } })) as typeof fetch, + fetch: (async () => new Response(big, { headers: { "content-type": "text/plain" } })) as ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise, }); expect(p.bytes).toBe(512 * 1024); expect(p.truncated).toBe(true); @@ -190,22 +198,28 @@ describe("读取网页", () => { it("非文本类型拒绝;重定向到内网拒绝;超时", async () => { const pdf = await fetchPage("https://example.org/a.pdf", { resolve: publicDns, - fetch: (async () => new Response("%PDF", { headers: { "content-type": "application/pdf" } })) as typeof fetch, + fetch: (async () => new Response("%PDF", { headers: { "content-type": "application/pdf" } })) as ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise, }).catch((e) => e); expect((pdf as WebSearchError).kind).toBe("unsupported"); const redir = await fetchPage("https://example.org/r", { resolve: publicDns, - fetch: (async () => - new Response(null, { status: 302, headers: { location: "http://192.168.1.1/admin" } })) as typeof fetch, + fetch: (async () => new Response(null, { status: 302, headers: { location: "http://192.168.1.1/admin" } })) as ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise, }).catch((e) => e); expect((redir as WebSearchError).kind).toBe("blocked"); const slow = await fetchPage("https://example.org/slow", { resolve: publicDns, timeoutMs: 50, fetch: ((_u: string, init: RequestInit) => - new Promise((_r, rej) => - init.signal?.addEventListener("abort", () => rej(new Error("aborted"))), - )) as typeof fetch, + new Promise((_r, rej) => init.signal?.addEventListener("abort", () => rej(new Error("aborted"))))) as ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise, }).catch((e) => e); expect((slow as WebSearchError).kind).toBe("timeout"); }); diff --git a/packages/adapters/test/differential/export-inputs.ts b/packages/adapters/test/differential/export-inputs.ts index b68e370d..e0ed8fad 100644 --- a/packages/adapters/test/differential/export-inputs.ts +++ b/packages/adapters/test/differential/export-inputs.ts @@ -1,6 +1,6 @@ /** * 把 @umt/testkit 的 mock RSS / NexusPHP fixture 导出成文件,供 MP 差分生成脚本(scripts/mp-sync/gen-differential.py rss|indexer)读取。 - * 运行:deno run -A packages/adapters/test/differential/export-inputs.ts + * 运行:bun packages/adapters/test/differential/export-inputs.ts * 只写 `inputs/rss/tk-*.xml` 与 `inputs/indexer/tk-*.html.txt`;手写样本(hw-*)不受影响。 */ import { mkdirSync, writeFileSync } from "node:fs"; diff --git a/packages/agent/deno.json b/packages/agent/deno.json deleted file mode 100644 index bc625d26..00000000 --- a/packages/agent/deno.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@umt/agent", - "version": "0.3.44", - "exports": { ".": "./src/index.ts" }, - "tasks": { - "test": "deno run -A npm:vitest run", - "typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json" - } -} diff --git a/packages/agent/package.json b/packages/agent/package.json index 24d77c3e..ec25fc51 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -4,5 +4,13 @@ "type": "module", "exports": { ".": "./src/index.ts" + }, + "version": "0.3.44", + "scripts": { + "test": "bun x --bun vitest run", + "typecheck": "bun x --bun tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@umt/contracts": "workspace:*" } } diff --git a/packages/agent/skills/release-facts/SKILL.md b/packages/agent/skills/release-facts/SKILL.md new file mode 100644 index 00000000..4d5e1ab9 --- /dev/null +++ b/packages/agent/skills/release-facts/SKILL.md @@ -0,0 +1,39 @@ +--- +name: release-facts +title: 资源属性补全 +description: 收录规则因分辨率、编码、来源或发布组等属性未知无法判断时,查看资源详情并查证缺失事实,补齐后由原规则重新判断。 +triggers: + - 资源属性(未知|缺失|补全) + - release_facts +capabilities: + - release.inspect + - release.apply_facts + - decision.list + - web.search + - web.fetch +sessions: [chat, investigate, child] +--- + +## 适用意图 + +收录规则所依赖的分辨率、编码、来源或发布组未知,资源属性事项等待查证后重评。 + +## 步骤 + +1. 查看事项与缺失字段,用 release.inspect(refreshDetail: true)读取详情、MediaInfo 和文件信息。刷新失败时说明错误,不能把旧页作为刷新成功的证据。 +2. 只补齐该事项缺少且有明确证据的属性。分辨率写实际规格;编码、来源和发布组按详情记载,不从作品类型猜测。 +3. 有持续授权时调用 release.apply_facts,带事项、资源、事实和具体出处。原生详情刷新已经补齐字段时可以提交空 facts,服务端核实后触发重评。 +4. 无授权或证据不足时保留明确建议,交给用户协同处理。不要修改收录范围、保留目标、组合优先级,也不要直接选另一个资源替代规则判断。 +5. 补齐事实不代表资源合格或已下载;最终以原规则重新评估的结果为准。 + +## 需要批准 + +`release.apply_facts` 需要覆盖本作品的专用持续授权;已有下载或身份授权不替代它。只读详情与网页查询沿用读取权限。 + +## 汇报模板 + +已查证资源编码为 H.265,依据详情页 MediaInfo;补齐后已交原收录规则重新判断。若证据不足,说明具体缺哪项资料与下一步。 + +## 不要做 + +不改允许分辨率、保留目标、组合排序、作品身份和季集映射;不覆盖已明确的解析值,不把未知当作不满足,也不把补齐等同于已经下载。 diff --git a/packages/agent/src/prompt/layers/capabilities.ts b/packages/agent/src/prompt/layers/capabilities.ts index 03b3e929..70925429 100644 --- a/packages/agent/src/prompt/layers/capabilities.ts +++ b/packages/agent/src/prompt/layers/capabilities.ts @@ -51,6 +51,7 @@ export const INTENT_ROUTES: readonly IntentRoute[] = [ { cap: "inventory.query", note: "库里已有的文件与收录版本" }, { cap: "release.search", note: "找可下载的资源" }, { cap: "release.inspect", note: "看某条资源的文件清单与促销" }, + { cap: "release.apply_facts", note: "查证后补齐缺失资源属性,再由规则判断" }, { cap: "download.plan", note: "准备下载,返回是否需要批准" }, { cap: "download.submit", note: "提交到下载器" }, ], diff --git a/packages/agent/src/prompt/skills.ts b/packages/agent/src/prompt/skills.ts index 0c5405c3..0952ab27 100644 --- a/packages/agent/src/prompt/skills.ts +++ b/packages/agent/src/prompt/skills.ts @@ -17,8 +17,10 @@ import type { PromptSection, SessionKind, SkillDoc } from "./types.ts"; // pi 1.x 把 harness 移到 pi-durable 后不再提供 skills,这里只保留本项目用到的部分: // 一层目录下的 /SKILL.md,不读 .gitignore、不收根目录的散 .md、不跟随符号链接。 -/** 仓库内的 Skill 目录;deno compile 用 --include 嵌入(apps/server/deno.json 的 compile 任务) */ -export const SKILLS_DIR = fileURLToPath(new URL("../../skills", import.meta.url)); +/** 仓库内的 Skill 目录;bun build --compile 用 --include 嵌入(apps/server/package.json 的 compile 任务) */ +export const SKILLS_DIR = process.execPath.endsWith("/umt-server") + ? join(process.execPath.replace(/[^/]+$/, ""), "skills") + : fileURLToPath(new URL("../../skills", import.meta.url)); const DEFAULT_SESSIONS: SessionKind[] = ["chat", "patrol", "investigate", "child"]; const SESSION_KINDS = new Set(["chat", "patrol", "investigate", "child", "vision"]); diff --git a/packages/agent/test/runtime.test.ts b/packages/agent/test/runtime.test.ts index a5cde541..43d3a8db 100644 --- a/packages/agent/test/runtime.test.ts +++ b/packages/agent/test/runtime.test.ts @@ -354,14 +354,10 @@ describe("PiAgentRuntime + FakeModelProvider", () => { it("JSONL 恢复:子进程在 download.submit 执行中被 SIGKILL,新进程 resume 得到“结果未知”且不重复提交", async () => { const sessionDir = await tempDir(); - const child = spawn( - process.execPath, - ["run", "-A", join(import.meta.dirname!, "fixtures/crash-run.ts"), sessionDir], - { - cwd: join(import.meta.dirname!, ".."), - stdio: ["ignore", "pipe", "pipe"], - }, - ); + const child = spawn(process.execPath, [join(import.meta.dirname!, "fixtures/crash-run.ts"), sessionDir], { + cwd: join(import.meta.dirname!, ".."), + stdio: ["ignore", "pipe", "pipe"], + }); let stderr = ""; child.stderr.on("data", (d) => { stderr += String(d); diff --git a/packages/agent/test/upstream.test.ts b/packages/agent/test/upstream.test.ts index 83980aa0..6cb375f6 100644 --- a/packages/agent/test/upstream.test.ts +++ b/packages/agent/test/upstream.test.ts @@ -150,14 +150,14 @@ describe("按上游装配", () => { it("测试补全带 sessionId:OpenCode Go 请求头有 x-opencode-session", async () => { const seen: { url: string; headers: Headers }[] = []; const orig = globalThis.fetch; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const req = new Request(input, init); + globalThis.fetch = (async (input: string | Request | URL, init?: RequestInit) => { + const req = new Request(input instanceof Request ? input : String(input), init); seen.push({ url: req.url, headers: req.headers }); return new Response(JSON.stringify({ error: { message: "stubbed" } }), { status: 500, headers: { "content-type": "application/json" }, }); - }) as typeof fetch; + }) as typeof globalThis.fetch; try { const c = new PiUpstreamClient( spec({ provider: "opencode-go", authKind: "api_key" }), diff --git a/packages/contracts/deno.json b/packages/contracts/deno.json deleted file mode 100644 index 5c70cb11..00000000 --- a/packages/contracts/deno.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@umt/contracts", - "version": "0.3.44", - "exports": { ".": "./src/index.ts" }, - "tasks": { - "test": "deno run -A npm:vitest run", - "typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json" - } -} diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 6aea4e3d..46466ab4 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -4,5 +4,11 @@ "type": "module", "exports": { ".": "./src/index.ts" - } + }, + "version": "0.3.44", + "scripts": { + "test": "bun x --bun vitest run", + "typecheck": "bun x --bun tsc --noEmit -p tsconfig.json" + }, + "dependencies": {} } diff --git a/packages/contracts/src/cache.ts b/packages/contracts/src/cache.ts index 8ad1ca6f..f45fa1bf 100644 --- a/packages/contracts/src/cache.ts +++ b/packages/contracts/src/cache.ts @@ -76,6 +76,37 @@ export function tmdbImageUrl(path: string | null | undefined, size: TmdbImageSiz return `/api/v1/img/tmdb/${size}/${p}`; } +/** + * 外部图片代理允许的域名:豆瓣图床(img1–9.doubanio.com 等)与 Bangumi 图床。豆瓣图床对不带 Referer 的请求回 418, + * 浏览器 no-referrer 直连拿不到图,所以经服务端带上来源站的 Referer 取(MP SECURITY_IMAGE_DOMAINS 同此)。 + */ +export const EXTERNAL_IMAGE_HOSTS: readonly { suffix: string; referer: string }[] = [ + { suffix: ".doubanio.com", referer: "https://movie.douban.com/" }, + { suffix: "lain.bgm.tv", referer: "https://bgm.tv/" }, +]; + +/** 外部图片地址是否在代理白名单里(只认 https、图片扩展名) */ +export function externalImageReferer(url: string): string | null { + let u: URL; + try { + u = new URL(url); + } catch { + return null; + } + if (u.protocol !== "https:" || !/\.(jpe?g|png|webp)$/i.test(u.pathname)) return null; + const host = u.hostname.toLowerCase(); + const hit = EXTERNAL_IMAGE_HOSTS.find((h) => + h.suffix.startsWith(".") ? host.endsWith(h.suffix) : host === h.suffix, + ); + return hit?.referer ?? null; +} + +/** 豆瓣 / Bangumi 封面 → 本站图片代理 /api/v1/img/ext?u=…;不在白名单的原样返回 */ +export function externalImageUrl(url: string | null | undefined): string | null { + if (!url) return null; + return externalImageReferer(url) ? `/api/v1/img/ext?u=${encodeURIComponent(url)}` : url; +} + // ── 通用缓存(kv_cache)按 region 管理(设置 → 缓存) ── /** region 名:小写字母、数字、`.`、`_`、`-` */ diff --git a/packages/contracts/src/capabilities-io.ts b/packages/contracts/src/capabilities-io.ts index 7824103a..adbfbb9b 100644 --- a/packages/contracts/src/capabilities-io.ts +++ b/packages/contracts/src/capabilities-io.ts @@ -40,6 +40,7 @@ import { SiteKind, SiteLastHealth, SiteLoginResult, + SiteReleasePage, SiteUserDataPoint, SourceRelease, } from "./sources.ts"; @@ -116,7 +117,31 @@ export const ReleaseSearchOutput = z.object({ }); export const ReleaseInspectInput = z.object({ sourceReleaseId: Id, refreshDetail: z.boolean().default(false) }); -export const ReleaseInspectOutput = z.object({ release: SourceRelease, inventory: z.array(MediaAsset) }); +export const ReleaseInspectOutput = z.object({ + release: SourceRelease, + inventory: z.array(MediaAsset), + page: SiteReleasePage.nullable().optional(), + refreshError: z.string().nullable().optional(), +}); +export const ReleaseStaticFacts = z + .object({ + resolution: z.enum(["4320p", "2160p", "1440p", "1080p", "1080i", "720p", "576p", "480p"]).optional(), + videoCodec: z.enum(["H.265", "H.264", "AV1", "VC-1", "MPEG-2", "XviD", "DivX"]).optional(), + source: z.enum(["REMUX", "BluRay-Disc", "BluRay", "WEB-DL", "WEBRip", "HDTV", "HDRip", "DVDRip", "DVD"]).optional(), + releaseGroup: z.string().trim().min(1).max(200).optional(), + }) + .strict(); +export type ReleaseStaticFacts = z.infer; +export const ReleaseApplyFactsInput = z + .object({ + decisionId: Id, + sourceReleaseId: Id, + facts: ReleaseStaticFacts, + evidence: z.string().trim().min(10).max(2000), + evidenceUrl: z.url().optional(), + }) + .strict(); +export const ReleaseApplyFactsOutput = z.object({ resolved: z.boolean(), remaining: z.array(z.string()) }); export const IdentityResolveInput = z .object({ @@ -1136,6 +1161,7 @@ export const CAPABILITY_IO = { "catalog.lookup": { input: CatalogLookupInput, output: CatalogLookupOutput }, "release.search": { input: ReleaseSearchInput, output: ReleaseSearchOutput }, "release.inspect": { input: ReleaseInspectInput, output: ReleaseInspectOutput }, + "release.apply_facts": { input: ReleaseApplyFactsInput, output: ReleaseApplyFactsOutput }, "identity.resolve": { input: IdentityResolveInput, output: IdentityResolveOutput }, "download.plan": { input: DownloadPlanInput, output: DownloadPlanOutput }, "download.submit": { input: DownloadSubmitInput, output: DownloadSubmitOutput }, diff --git a/packages/contracts/src/capability.ts b/packages/contracts/src/capability.ts index 93cfca50..7c57090b 100644 --- a/packages/contracts/src/capability.ts +++ b/packages/contracts/src/capability.ts @@ -57,6 +57,7 @@ export const CAPABILITY_IDS = [ "catalog.lookup", "release.search", "release.inspect", + "release.apply_facts", "identity.resolve", "download.plan", "download.submit", @@ -185,6 +186,18 @@ export const CAPABILITY_CATALOG: Record true, true, ), + "release.apply_facts": d( + "release.apply_facts", + "补全资源属性", + "查证资源详情后,仅补全事项里缺失的分辨率、编码、来源或发布组;规则重新判断收录。不改变已有事实和策略。", + "execute", + "ReleaseApplyFacts", + "organize.execute", + "medium", + true, + true, + "safe", + ), "download.plan": d( "download.plan", "准备下载", @@ -704,6 +717,7 @@ export const CAPABILITY_ACTIVITY: Record "catalog.lookup": act("查", "作品信息"), "release.search": act("搜", "资源", "次"), "release.inspect": act("看", "资源", "个"), + "release.apply_facts": act("补全", "资源属性", "个"), "identity.resolve": act("识别", "作品", "个"), "download.plan": { ...act("准备下载", "资源", "个"), bare: true }, "download.submit": act("提交", "下载", "个"), diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index 879b3101..eceb3111 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -184,6 +184,7 @@ export const ReleaseResolvedPayload = z.object({ }); export const DecisionCreatedPayload = z.object({ + dedupeKey: z.string().optional(), decisionId: Id, kind: DecisionKind, severity: Severity, diff --git a/packages/contracts/src/features.ts b/packages/contracts/src/features.ts index b0db4dd1..92dfd2a2 100644 --- a/packages/contracts/src/features.ts +++ b/packages/contracts/src/features.ts @@ -1,6 +1,5 @@ import type { KnownCapabilityId } from "./capability.ts"; import type { EventType } from "./events.ts"; -import type { HookPoint } from "./hooks.ts"; // 功能模块约定(ADR-006):外围功能各自声明一份 manifest,汇总成功能清单。 // 功能清单只给智能体看(系统提示的「功能清单」一节),不做开关;前端插槽默认直接显示。 @@ -31,8 +30,6 @@ export interface FeatureManifest { pages: readonly string[]; /** 挂在哪些前端插槽 */ slots: readonly SlotPosition[]; - /** 注册的链式钩子 */ - hooks: readonly HookPoint[]; } /** 功能清单。新增外围功能:在这里加一份,服务端模块 manifest 指向它(featureManifest(id)) */ @@ -47,7 +44,6 @@ export const FEATURES = [ capabilities: [], pages: ["作品页「补字幕」", "整理记录行菜单「补字幕」", "站点页「字幕站」"], slots: ["entity.actions", "organize.record.actions"], - hooks: [], }, { id: "browser", @@ -64,7 +60,6 @@ export const FEATURES = [ ], pages: ["站点页「原站视图」"], slots: [], - hooks: [], }, { id: "desktop", @@ -75,7 +70,6 @@ export const FEATURES = [ capabilities: [], pages: ["原站视图「桌面模式」"], slots: [], - hooks: [], }, { id: "websearch", @@ -86,7 +80,6 @@ export const FEATURES = [ capabilities: ["web.search", "web.fetch"], pages: ["设置 → 智能体 → 网页搜索"], slots: [], - hooks: [], }, { id: "mcp", @@ -97,7 +90,6 @@ export const FEATURES = [ capabilities: [], pages: ["设置 → 智能体 → 工具与 MCP"], slots: [], - hooks: [], }, { id: "schedules", @@ -108,7 +100,6 @@ export const FEATURES = [ capabilities: ["schedule.draft_check"], pages: ["日志 → 定时任务"], slots: [], - hooks: [], }, ] as const satisfies readonly FeatureManifest[]; diff --git a/packages/contracts/src/hooks.ts b/packages/contracts/src/hooks.ts deleted file mode 100644 index c4ebc160..00000000 --- a/packages/contracts/src/hooks.ts +++ /dev/null @@ -1,44 +0,0 @@ -// 链式钩子挂点(ADR-006,参考 MoviePilot ChainEventType):同步、按注册顺序执行、可改写或拦截。 -// 这里只登记挂点名称与语义(给功能清单、智能体提示与文档用);入参 / 返回值类型在服务端 -// apps/server/src/hooks/points.ts,因为它们用到 domain 的解析结果等类型。 - -/** - * - rewrite:每个钩子拿到上一个钩子的结果,返回新值或 undefined(不改); - * - intercept:任一钩子返回拒绝原因即停止,后面的钩子不再执行。 - */ -export type HookKind = "rewrite" | "intercept"; - -export const HOOK_POINTS = { - "name.recognize": { - kind: "rewrite", - label: "名称识别", - when: "资源标题解析之后、按名称召回作品之前", - effect: "改写解析结果(标题、年份、季集、外部 ID)", - }, - "organize.rename": { - kind: "rewrite", - label: "整理改名", - when: "按命名模板生成目标路径之后", - effect: "改写主视频的目标文件名(同主干的字幕等附属文件跟着改)", - }, - "organize.intercept": { - kind: "intercept", - label: "整理拦截", - when: "整理计划生成后、写整理记录之前", - effect: "返回原因即拒绝本次整理(记录为失败,原因 intercepted:<原因>)", - }, - "resource.selection": { - kind: "rewrite", - label: "资源选择", - when: "订阅 / 收录版本重评时候选排好序之后、逐条评估之前", - effect: "调整候选顺序或过滤掉一部分(不能加入新候选)", - }, - "subscription.completion.check": { - kind: "intercept", - label: "订阅完成判定", - when: "订阅满足完成条件、标记完成之前", - effect: "追加完成条件:返回原因即本次不完成,订阅继续追踪", - }, -} as const satisfies Record; - -export type HookPoint = keyof typeof HOOK_POINTS; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f5c8b8bd..f978579f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -15,7 +15,6 @@ export * from "./episode-mapping.ts"; export * from "./events.ts"; export * from "./execution.ts"; export * from "./features.ts"; -export * from "./hooks.ts"; export * from "./identify-words.ts"; export * from "./identity.ts"; export * from "./instances.ts"; diff --git a/packages/contracts/src/policy.ts b/packages/contracts/src/policy.ts index 5dcd23b1..46545b09 100644 --- a/packages/contracts/src/policy.ts +++ b/packages/contracts/src/policy.ts @@ -85,7 +85,7 @@ export const Subscription = EntityBase.extend({ mediaEntityTitle: z.string().nullable().optional(), }); export type Subscription = z.infer; -/** 自动补全其他版本:inherit = 取引用规则组(后者优先)的 autoFill,都没设为开 */ +/** 自动补全其他版本:inherit = 取主版本收录规则链(后者优先)的 autoFill,都没设为关 */ export const AutoFillMode = z.enum(["inherit", "on", "off"]); export type AutoFillMode = z.infer; /** 补全预算:只约束非主版本的自动补全计划(按订阅、按自然日);同时仍计入全局每日预算 */ @@ -121,7 +121,8 @@ export type SubscriptionEffect = z.infer; * siteIds 站点范围;episodeRange 集范围(对照 MP start/total_episode);searchIntervalHours 主动搜索周期(0 = 关); * stopAfter 截止时间;downloaderInstanceId 指定下载器;ruleGroupIds 规则组;keywords 追加检索词; * include/exclude 订阅级关键词正则(作用于全部收录版本);libraryRootId 库根;downloadSavePath 下载保存路径; - * autoFill/fillBudget 多版本自动补全;resolution/source/effect 质量预设(MP resolution/quality/effect)。 + * autoFill/fillBudget 多版本自动补全;resolution/source/effect 仅保留 MP 导入/API 质量预设兼容。 + * 正常界面逐版本维护质量和规则引用,普通订阅保存保留旧兼容字段。 * include/exclude 保存时同步为订阅私有规则组(scope.subscriptionId,随订阅删除),评估只读规则组。 * 写入时兼容 MP 字段与旧键(sites/downloader/save_path/start_episode/filter_groups/keyword/autoFillVersions…), * 服务端规范化后只存本表列出的键,见 docs/runbook/subscriptions.md。 @@ -714,6 +715,17 @@ export type PreferenceSuppressionUpdate = z.infer; + /** Slot 模板:策略规则里用于自动建 Slot(新作品收录 / 订阅缺省 Slot);字段同 AcquisitionSlot 规则列 */ export const SlotTemplate = z.object({ name: z.string().min(1).max(64), diff --git a/packages/contracts/test/schemas.test.ts b/packages/contracts/test/schemas.test.ts index 33e9f25d..29769c75 100644 --- a/packages/contracts/test/schemas.test.ts +++ b/packages/contracts/test/schemas.test.ts @@ -229,16 +229,20 @@ describe("关键合同行为", () => { } }); - it("capabilitySpec 组装目录元数据与真实 schema;有副作用的类别不可重放", () => { + it("capabilitySpec 组装目录元数据与真实 schema;写入仅明确的幂等例外可重放", () => { for (const id of C.CAPABILITY_IDS) { const spec = C.capabilitySpec(id); const { inputSchema: _i, outputSchema: _o, ...meta } = C.CAPABILITY_CATALOG[id]; expect(spec).toMatchObject(meta); expect(spec.inputSchema).toBe(C.CAPABILITY_IO[id].input); expect(spec.outputSchema).toBe(C.CAPABILITY_IO[id].output); - // 例外:site.health_check 只读首页并写回健康列,幂等可重跑;metadata.enrich 只存补全建议(7 天标题缓存),重跑无副作用 - if (spec.kind !== "read" && spec.kind !== "preview" && id !== "site.health_check" && id !== "metadata.enrich") - expect(spec.replay, id).toBe("never"); + // 健康列、补全建议、带事项与事件去重的资源事实可幂等重跑;其它写入仍不可重放。 + if (spec.kind !== "read" && spec.kind !== "preview") { + if (["site.health_check", "metadata.enrich", "release.apply_facts"].includes(id)) { + expect(spec.replay, id).toBe("safe"); + expect(spec.idempotent, id).toBe(true); + } else expect(spec.replay, id).toBe("never"); + } } }); diff --git a/packages/db/README.md b/packages/db/README.md index bab8c655..43450eef 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -5,7 +5,7 @@ PostgreSQL 访问层:Kysely 客户端与连接池、表类型(`src/types.ts`)、 ## 规则 - **迁移是唯一事实来源**。所有 schema 变更只能新增迁移,已发布迁移不得修改。迁移用原生 DDL 书写(`src/migration-helpers.ts`:`entityTable()` 统一加 uuid 主键 `gen_random_uuid()`、`created_at/updated_at` 与 updated_at 触发器;`oneOf()` 生成状态 CHECK)。`migrations/` 内相对导入必须带 `.ts` 扩展名(Kysely 用原生 `import()` 加载)。 -- **`src/types.ts` 是生成物**:`deno task db:migrate && deno task gen:types`。CHECK 枚举自动变成字面量联合(如 `AcquisitionSlotStatus`)。`test/schema.test.ts` 会在隔离 schema 里重新反射并比对,漂移即失败;`packages/contracts/test/db-sync.test.ts` 再校验 contracts 的 Row schema 与枚举和这里一致。 +- **`src/types.ts` 是生成物**:`bun run db:migrate && bun run gen:types`。CHECK 枚举自动变成字面量联合(如 `AcquisitionSlotStatus`)。`test/schema.test.ts` 会在隔离 schema 里重新反射并比对,漂移即失败;`packages/contracts/test/db-sync.test.ts` 再校验 contracts 的 Row schema 与枚举和这里一致。 - 状态列一律 `text + CHECK`,不用 PG enum(便于迁移加值)。JSONB 只放证据、规则参数、原文快照与演进字段;稳定查询字段用关系列。 - 凭证只存 `secret_ref`(→ `secret.id`),明文不入库、不进日志。 - `createDb()` 把 `int8` 解析为 `number`(超出 2^53 抛错)、`date` 保持 `YYYY-MM-DD` 字符串。写 jsonb 数组时须先 `JSON.stringify`(pg 会把 JS 数组当 PG 数组)。 diff --git a/packages/db/deno.json b/packages/db/deno.json deleted file mode 100644 index 8c3591db..00000000 --- a/packages/db/deno.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@umt/db", - "version": "0.3.44", - "exports": { ".": "./src/index.ts" }, - "tasks": { - "test": "deno run -A npm:vitest run", - "typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json", - "migrate": "deno run -A --env-file=../../.env scripts/migrate.ts latest", - "reset": "deno run -A --env-file=../../.env scripts/migrate.ts reset", - "gen:types": "deno run -A --env-file=../../.env scripts/gen-types.ts" - } -} diff --git a/packages/db/package.json b/packages/db/package.json index 194b870c..4c3525bb 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -4,5 +4,16 @@ "type": "module", "exports": { ".": "./src/index.ts" + }, + "version": "0.3.44", + "scripts": { + "test": "bun x --bun vitest run", + "typecheck": "bun x --bun tsc --noEmit -p tsconfig.json", + "migrate": "bun --env-file=../../.env scripts/migrate.ts latest", + "reset": "bun --env-file=../../.env scripts/migrate.ts reset", + "gen:types": "bun --env-file=../../.env scripts/gen-types.ts" + }, + "dependencies": { + "@umt/runtime": "workspace:*" } } diff --git a/packages/db/scripts/gen-types-lib.ts b/packages/db/scripts/gen-types-lib.ts index 6c8646cb..e45512a5 100644 --- a/packages/db/scripts/gen-types-lib.ts +++ b/packages/db/scripts/gen-types-lib.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url"; import { type Kysely, sql } from "kysely"; // 从已迁移的数据库反射出 src/types.ts:列类型 + CHECK (col in (...)) 枚举字面量联合。 -// 迁移是唯一事实来源;改表后运行 `deno task gen:types`,测试会校验两者无漂移。 +// 迁移是唯一事实来源;改表后运行 `bun run gen:types`,测试会校验两者无漂移。 interface ColumnInfo { table_name: string; @@ -77,7 +77,7 @@ export async function generateTypesSource(db: Kysely): Promise { const out: string[] = [ "// 本文件由 scripts/gen-types.ts 从迁移后的数据库反射生成,请勿手改。", - "// 改表:新增迁移 → deno task db:migrate → deno task gen:types。", + "// 改表:新增迁移 → bun run db:migrate → bun run gen:types。", "", 'import type { ColumnType, Generated, Insertable, Selectable, Updateable } from "kysely";', "", diff --git a/packages/db/scripts/gen-types.ts b/packages/db/scripts/gen-types.ts index 588dc720..118b20fe 100644 --- a/packages/db/scripts/gen-types.ts +++ b/packages/db/scripts/gen-types.ts @@ -1,4 +1,4 @@ -// 用法:deno task gen:types —— 对 DATABASE_URL 的当前 schema 反射并重写 src/types.ts +// 用法:bun run gen:types —— 对 DATABASE_URL 的当前 schema 反射并重写 src/types.ts import { writeFileSync } from "node:fs"; import { createDb } from "../src/index.ts"; import { formatWithBiome, generateTypesSource, TYPES_FILE } from "./gen-types-lib.ts"; diff --git a/packages/db/scripts/migrate.ts b/packages/db/scripts/migrate.ts index 9b12514e..cd680c34 100644 --- a/packages/db/scripts/migrate.ts +++ b/packages/db/scripts/migrate.ts @@ -1,4 +1,4 @@ -// 用法:deno task db:migrate / db:reset(= deno run -A --env-file=.env packages/db/scripts/migrate.ts latest|reset) +// 用法:bun run db:migrate / db:reset(= bun --env-file=.env packages/db/scripts/migrate.ts latest|reset) import { createDb, migrateToLatest, resetSchema } from "../src/index.ts"; const cmd = process.argv[2] ?? "latest"; diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index baa5fa61..5105e31f 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -1,5 +1,5 @@ // 本文件由 scripts/gen-types.ts 从迁移后的数据库反射生成,请勿手改。 -// 改表:新增迁移 → deno task db:migrate → deno task gen:types。 +// 改表:新增迁移 → bun run db:migrate → bun run gen:types。 import type { ColumnType, Generated, Insertable, Selectable, Updateable } from "kysely"; diff --git a/packages/db/test/fixtures/bucket-worker.ts b/packages/db/test/fixtures/bucket-worker.ts index 06168fed..32b14e55 100644 --- a/packages/db/test/fixtures/bucket-worker.ts +++ b/packages/db/test/fixtures/bucket-worker.ts @@ -3,7 +3,7 @@ import { Kysely, PostgresDialect } from "kysely"; import pg from "pg"; import { PgTokenBucket } from "../../src/rate-limit.ts"; -const [url, schema, key, n, capacity, rate] = Deno.args as [string, string, string, string, string, string]; +const [url, schema, key, n, capacity, rate] = process.argv.slice(2) as [string, string, string, string, string, string]; const db = new Kysely({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: url, max: 1, options: `-c search_path=${schema},public` }), diff --git a/packages/db/test/rate-limit.test.ts b/packages/db/test/rate-limit.test.ts index 4156fd8f..2d39320e 100644 --- a/packages/db/test/rate-limit.test.ts +++ b/packages/db/test/rate-limit.test.ts @@ -1,4 +1,5 @@ import { fileURLToPath } from "node:url"; +import { Command } from "@umt/runtime"; import { Kysely, PostgresDialect, sql } from "kysely"; import pg from "pg"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -70,8 +71,8 @@ describe.skipIf(skip)("PgTokenBucket(真实 PG)", () => { const rate = 20; const perProc = 12; const procs = [0, 1, 2].map(() => - new Deno.Command(Deno.execPath(), { - args: ["run", "-A", script, url, schema, "t:procs", String(perProc), String(capacity), String(rate)], + new Command(process.execPath, { + args: [script, url, schema, "t:procs", String(perProc), String(capacity), String(rate)], stdout: "piped", stderr: "piped", }).output(), diff --git a/packages/domain/README.md b/packages/domain/README.md index 3b75c322..d548fbf7 100644 --- a/packages/domain/README.md +++ b/packages/domain/README.md @@ -3,8 +3,8 @@ 纯业务逻辑(无 IO):发布标题解析、身份匹配、Slot 满足度、收录模式/预算/磁盘阈值、路径绑定与命名、整理计划、下载状态映射。 - 禁止 import 任何网络/文件/数据库库,也不读全局配置与时钟;时间(`now`)、设备号、文件清单、已存在目标等事实一律由调用方传入。 -- 不依赖 `@umt/contracts`:这里的类型是领域类型,W2 在 server/tasks 层映射到 zod 合同。 -- 唯一入口 `src/index.ts`(`export *` 各模块)。测试:`cd packages/domain && deno task test`。 +- 同一语义的类型和纯规则可以依赖 `@umt/contracts`,例如 `ContentType`;含义不同的领域类型通过 `@umt/mapping` 转换。保持无 IO,不依赖 server / db / adapters。 +- 唯一入口 `src/index.ts`(`export *` 各模块)。测试:`cd packages/domain && bun run test`。 ## 模块地图 @@ -49,7 +49,7 @@ | move 跨盘 | EXDEV 隐式降级复制,删源失败仍成功 | 只标记 `crossDevice`,执行层显式三步 | | Move 删种 | 不看 H&R | H&R 未满足/未知 → 阻塞冲突 | -## 给 W2 的接入提示 +## 接入提示 - 解析:下载完成后用 `parseReleasePath({ file, parent, grandParent })` 或 `planOrganize({ sourceRoot })`(只在种子根内取目录名);RSS/站点标题用 `parseRelease(title, { subtitle })`。`ParsedRelease.sizeBytes` 可用站点字段覆盖(`{ ...parsed, sizeBytes }`)后再交给 Slot 评估。 - 身份:本地别名召回(pg_trgm)与 TMDB 结果都转成 `IdentityCandidate`(本地用 `provider: "local"` + `localEntityId` + `linkedExternalIds`);`resolveIdentity` 为 `auto` 才自动绑定,其余进待识别池。 diff --git a/packages/domain/deno.json b/packages/domain/deno.json deleted file mode 100644 index fe66f926..00000000 --- a/packages/domain/deno.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@umt/domain", - "version": "0.3.44", - "exports": { ".": "./src/index.ts" }, - "tasks": { - "test": "deno run -A npm:vitest run", - "typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json" - } -} diff --git a/packages/domain/package.json b/packages/domain/package.json index bfa5d3e6..39727844 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -4,5 +4,13 @@ "type": "module", "exports": { ".": "./src/index.ts" + }, + "version": "0.3.44", + "scripts": { + "test": "bun x --bun vitest run", + "typecheck": "bun x --bun tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@umt/contracts": "workspace:*" } } diff --git a/packages/domain/src/acquisition/index.ts b/packages/domain/src/acquisition/index.ts index 49ac29a3..282dbe7a 100644 --- a/packages/domain/src/acquisition/index.ts +++ b/packages/domain/src/acquisition/index.ts @@ -25,6 +25,7 @@ export { resolveAutoFill, } from "./fill.ts"; export { type AcquisitionMode, determineMode, type ModeDecision, type ModeInput } from "./mode.ts"; +export { type ScopedAcquisitionPolicy, selectAcquisitionPolicy } from "./policy.ts"; export { DEFAULT_PRIORITY_ORDER, type PriorityClass, diff --git a/packages/domain/src/acquisition/policy.ts b/packages/domain/src/acquisition/policy.ts new file mode 100644 index 00000000..952fcd16 --- /dev/null +++ b/packages/domain/src/acquisition/policy.ts @@ -0,0 +1,30 @@ +import type { AcquisitionPolicyScope } from "@umt/contracts"; + +export interface ScopedAcquisitionPolicy { + id: string; + scope: AcquisitionPolicyScope; +} + +/** 输入已按发布时间排序;分类明确命中优先于类型默认,显式选择不回退到别的策略。 */ +export function selectAcquisitionPolicy( + policies: readonly T[], + input: { explicitId?: string | null; mediaKind: "movie" | "series" | "variety"; category: string | null }, +): T | undefined { + if (input.explicitId) return policies.find((p) => p.id === input.explicitId); + const type = input.mediaKind === "movie" ? "movie" : "tv"; + let selected: T | undefined; + let specificity = -1; + for (const p of policies) { + if (p.scope.mediaKinds?.length && !p.scope.mediaKinds.includes(input.mediaKind)) continue; + const categories = p.scope.categories; + if (categories && Object.values(categories).some((v) => v?.length)) { + if (!input.category || !categories[type]?.includes(input.category)) continue; + } + const score = categories?.[type]?.length ? 2 : p.scope.mediaKinds?.length ? 1 : 0; + if (score > specificity) { + selected = p; + specificity = score; + } + } + return selected; +} diff --git a/packages/domain/src/release-parser/types.ts b/packages/domain/src/release-parser/types.ts index 3062107d..512c63b6 100644 --- a/packages/domain/src/release-parser/types.ts +++ b/packages/domain/src/release-parser/types.ts @@ -1,3 +1,4 @@ +import type { ContentType } from "@umt/contracts"; import type { CompiledGlobalWord } from "../identify-words/apply.ts"; /** 解析器版本:规则变化时递增,缓存/识别结果按此失效 */ @@ -35,15 +36,8 @@ export type Cut = | "Ultimate" | "Special Edition"; -/** 综艺内容类型(REQ §5.1);v0.1 只识别标记,不做分段建模 */ -export type ContentType = - | "unknown" - | "main" - | "extra" - | "behind_the_scenes" - | "performance_only" - | "preview" - | "reaction"; +// 内容类型在 contracts 定义一次;领域与接口使用相同语义。 +export type { ContentType }; export type Segment = "full" | "upper" | "middle" | "lower"; diff --git a/packages/domain/src/rules/unknown.ts b/packages/domain/src/rules/unknown.ts new file mode 100644 index 00000000..511812e0 --- /dev/null +++ b/packages/domain/src/rules/unknown.ts @@ -0,0 +1,77 @@ +import type { ParsedRelease } from "../release-parser/types.ts"; +import type { HardField } from "../slot/types.ts"; +import { type AtomCatalog, testAtom } from "./evaluate.ts"; +import type { RuleContext, RuleExpr, RuleGroupSpec } from "./types.ts"; + +// 只对有明确事实字段的条件判断未知;正则文本缺词仍是已知不命中。 +const fields: Record = { + "4K": "resolution", + "1080P": "resolution", + "720P": "resolution", + BLU: "source", + BLURAY: "source", + REMUX: "source", + WEBDL: "source", + H265: "codec", + H264: "codec", + FREE: "promotion", +}; +type PartialResult = { hit: boolean | null; missing: HardField[] }; + +function partial(e: RuleExpr, r: ParsedRelease, ctx: RuleContext, catalog: AtomCatalog): PartialResult { + if (e.op === "atom") { + const id = e.id.toUpperCase(); + const f = fields[id]; + const value = + f === "resolution" + ? r.resolution + : f === "source" + ? r.source + : f === "codec" + ? r.videoCodec + : ctx.downloadVolumeFactor; + if (f && value === undefined) return { hit: null, missing: [f] }; + const custom = catalog.get(id); + const missing: HardField[] = []; + if (custom?.minSeeders !== undefined && ctx.seeders === undefined) missing.push("seeders"); + if (custom?.free && ctx.downloadVolumeFactor === undefined) missing.push("promotion"); + if (custom?.unitSizeMB && r.sizeBytes === undefined) missing.push("size"); + const input = { r, ctx, text: [r.raw ?? "", ctx.subtitle ?? "", ...(ctx.labels ?? [])].join(" ") }; + // 已知的正则条件失败时,其他缺失字段不会改变结论。 + if (custom && missing.length) { + const known = { ...custom, minSeeders: undefined, free: undefined, unitSizeMB: undefined }; + const knownCatalog = new Map(catalog).set(id, known); + if (!testAtom(id, input, knownCatalog).hit) return { hit: false, missing: [] }; + return { hit: null, missing }; + } + return { hit: testAtom(id, input, catalog).hit, missing: [] }; + } + if (e.op === "not") { + const result = partial(e.arg, r, ctx, catalog); + return { ...result, hit: result.hit === null ? null : !result.hit }; + } + const results = e.args.map((a) => partial(a, r, ctx, catalog)); + const decisive = e.op !== "and"; + if (results.some((x) => x.hit === decisive)) return { hit: decisive, missing: [] }; + const unknown = results.filter((x) => x.hit === null); + return unknown.length ? { hit: null, missing: unknown.flatMap((x) => x.missing) } : { hit: !decisive, missing: [] }; +} + +/** 未知的高档不能被当成不命中而落入低档,NOT 也不能把未知当成事实。 */ +export function unknownRuleFields( + groups: readonly RuleGroupSpec[], + r: ParsedRelease, + ctx: RuleContext, + catalog: AtomCatalog, +): HardField[] { + const missing: HardField[] = []; + for (const group of groups) { + for (const expr of group.hardExcludes ?? []) missing.push(...partial(expr, r, ctx, catalog).missing); + for (const layer of group.layers) { + const result = partial(layer.expr, r, ctx, catalog); + missing.push(...result.missing); + if (result.hit === true) break; + } + } + return [...new Set(missing)]; +} diff --git a/packages/domain/src/slot/evaluate.ts b/packages/domain/src/slot/evaluate.ts index 2cc9747e..deffb61d 100644 --- a/packages/domain/src/slot/evaluate.ts +++ b/packages/domain/src/slot/evaluate.ts @@ -1,6 +1,8 @@ import type { ParsedRelease, ReleaseSource, Resolution } from "../release-parser/types.ts"; import { catalogOf, evaluateRuleChain } from "../rules/evaluate.ts"; import type { RuleChainResult } from "../rules/types.ts"; +import { unknownRuleFields } from "../rules/unknown.ts"; +import { seasonReleaseKey, upgradeSpec } from "./season-release.ts"; import type { EvaluationContext, HardField, @@ -310,6 +312,8 @@ export function describeViolation(v: Violation): string { return `优先级没有提高(现有 ${JSON.stringify(e)},这个资源 ${JSON.stringify(a)})`; case "upgrade.not_full_pack": return "全集洗版只接受整季包"; + case "upgrade.different_season_release": + return "与当季选定的成套版本不同,等待同套资源"; case "upgrade.no_improvable_units": return "这个资源包含的季集都没有可升级的"; default: @@ -348,6 +352,37 @@ function tierFailures(rule: SlotTierRule, r: ParsedRelease, ctx: EvaluationConte return out; } +function unknownTierFields(spec: SlotSpec, r: ParsedRelease, ctx: EvaluationContext): HardField[] { + for (const tier of spec.soft?.tiers ?? []) { + if (!tierFailures(tier, r, ctx).length) break; + const missing: HardField[] = []; + const filled = { ...r }; + if (!r.resolution && tier.resolutions?.length) { + missing.push("resolution"); + filled.resolution = tier.resolutions[0]; + } + if (!r.source && (tier.sources?.length || tier.excludeSources?.length || tier.requireEncode)) { + missing.push("source"); + filled.source = + tier.sources?.[0] ?? + (tier.requireEncode + ? "BluRay" + : (Object.keys(DEFAULT_SOURCE_TIERS) as ReleaseSource[]).find((s) => !tier.excludeSources?.includes(s))); + filled.isEncode = tier.requireEncode ?? filled.source === "BluRay"; + } + if (!r.videoCodec && tier.codecs?.length) { + missing.push("codec"); + filled.videoCodec = tier.codecs[0]; + } + if (!r.releaseGroup && tier.groups?.length) { + missing.push("group"); + filled.releaseGroup = { name: tier.groups.join("@"), source: "known" }; + } + if (missing.length && !tierFailures(tier, filled, ctx).length) return missing; + } + return []; +} + /** * 优先级阶梯(对应 MP 规则组 `A > B > C`):逐层匹配,第 k 层(0 起)命中 → pri = 100 - k; * 未配置阶梯 → pri = 100、level = null(不参与排序差异);配置了但都不命中 → pri = 0。 @@ -465,9 +500,21 @@ export function evaluateCandidate( current?: QualityFacts | null, ctx: EvaluationContext = {}, ): SlotEvaluation { + if (current) spec = upgradeSpec(spec); const hard = checkHardConditions(spec, r, ctx); - const { missing, accepted } = hard; const chain = ruleChainOf(spec, r, ctx); + if (hard.violations.length === 0) { + const unknown = spec.rules ? unknownRuleFields(spec.rules.groups, r, ctx, catalogOf(spec.rules.atoms)) : []; + if (chain?.decidingGroup === undefined || chain.decidingGroup === null) + unknown.push(...unknownTierFields(spec, r, ctx)); + if (unknown.length) + return { + decision: "unknown_field", + missing: [...new Set([...hard.missing, ...unknown])], + trace: unknown.map((f) => `组合规则需要补充:${f}`), + }; + } + const { missing, accepted } = hard; const withRules = chain ? { rules: chain } : {}; const violations = [...hard.violations, ...chainViolations(chain, spec)]; const ladder = effectiveLadder(spec, r, ctx, chain); @@ -524,6 +571,53 @@ export function evaluateCandidate( const up = spec.upgrade; if (!up?.enabled) return reject({ field: "upgrade", code: "upgrade_disabled" }); if (up.mode === "full_pack" && (r.episodes || r.airDate)) return reject({ field: "upgrade", code: "not_full_pack" }); + if (r.isEncode && up.encodeGroupAllowlist?.length) { + if (!r.releaseGroup) return { decision: "unknown_field", missing: ["group"], trace: ["合格压制组需要查证发布组"] }; + const groups = r.releaseGroup ? r.releaseGroup.name.split("@").map(lc) : []; + if (!up.encodeGroupAllowlist.some((g) => groups.includes(lc(g)))) + return reject({ + field: "upgrade", + code: "encode_not_qualified", + expected: up.encodeGroupAllowlist, + actual: r.releaseGroup?.name, + }); + } + if (up.consistency === "season_release") { + const key = seasonReleaseKey(r); + const targetKey = ctx.seasonReleaseTarget ? seasonReleaseKey(ctx.seasonReleaseTarget) : null; + if (key && !targetKey) + return { decision: "unknown_field", missing: ["current_asset"], trace: ["当季成套目标的规格未知"] }; + if (!key) + return { + decision: "unknown_field", + missing: [ + ...(!r.resolution ? ["resolution" as const] : []), + ...(!r.source ? ["source" as const] : []), + ...(!r.videoCodec ? ["codec" as const] : []), + ...(!r.releaseGroup?.name ? ["group" as const] : []), + ], + trace: ["成套洗版需要完整版本规格"], + }; + if (key !== targetKey) return reject({ field: "upgrade", code: "different_season_release" }); + const fromPri = currentPri(spec, current); + if (key !== seasonReleaseKey(current) && ladder.pri === fromPri) { + const from = qualityTier(current, spec); + if (tier.source < from.source || tier.resolution < from.resolution) + return reject({ field: "upgrade", code: "lower_tier", expected: from, actual: tier }); + return { + decision: "upgrade", + tier, + fromTier: from, + softRank: rank, + reasons: [...reasons, "season_release_aligned", ...priReasons], + pri: ladder.pri, + fromPri, + ladder, + trace: ["保持优先级,向当季同套版本对齐", ...ladder.trace], + ...withRules, + }; + } + } const basis = up.basis ?? (tiered ? "priority" : "tier"); if (basis === "priority") { const fromPri = currentPri(spec, current); @@ -559,16 +653,6 @@ export function evaluateCandidate( dSource === 0 && dRes === 0 ? "same_tier" : dSource < 0 || dRes < 0 ? "lower_tier" : "below_min_improvement"; return reject({ field: "upgrade", code, expected: { minTierSteps: minSteps, from }, actual: tier }); } - if (r.isEncode && up.encodeGroupAllowlist?.length) { - const groups = r.releaseGroup ? r.releaseGroup.name.split("@").map(lc) : []; - if (!up.encodeGroupAllowlist.some((g) => groups.includes(lc(g)))) - return reject({ - field: "upgrade", - code: "encode_not_qualified", - expected: up.encodeGroupAllowlist, - actual: r.releaseGroup?.name, - }); - } return { decision: "upgrade", tier, diff --git a/packages/domain/src/slot/index.ts b/packages/domain/src/slot/index.ts index 368fbfc7..687eae13 100644 --- a/packages/domain/src/slot/index.ts +++ b/packages/domain/src/slot/index.ts @@ -59,6 +59,7 @@ export { type SubscriptionGroupResult, subscriptionGroup, } from "./overview.ts"; +export { seasonReleaseKey, selectSeasonRelease, upgradeSpec } from "./season-release.ts"; export { canTransition, type SlotEvent, diff --git a/packages/domain/src/slot/season-release.ts b/packages/domain/src/slot/season-release.ts new file mode 100644 index 00000000..1af819af --- /dev/null +++ b/packages/domain/src/slot/season-release.ts @@ -0,0 +1,49 @@ +import type { ParsedRelease } from "../release-parser/types.ts"; +import { compareRank, currentPri, qualityTier, softRank } from "./evaluate.ts"; +import type { QualityFacts, SlotSpec } from "./types.ts"; + +/** 缺少关键规格时不能声称同套;集号、文件名、种子 ID 不属于版本身份。 */ +export function seasonReleaseKey(q: QualityFacts): string | null { + if (!q.resolution || !q.source || !q.videoCodec || !q.releaseGroup?.name) return null; + return JSON.stringify([ + q.resolution, + q.source, + q.videoCodec, + q.releaseGroup.name + .toLowerCase() + .split("@") + .map((g) => g.trim()) + .sort(), + [...(q.hdr ?? [])].sort(), + q.cut ?? "default", + ]); +} + +export function upgradeSpec(spec: SlotSpec): SlotSpec { + return { + ...spec, + ...(spec.upgradeRules ? { rules: spec.upgradeRules } : {}), + ...(spec.upgradeHard ? { hard: spec.upgradeHard } : {}), + }; +} + +/** 优先级、软偏好、规范化规格形成稳定顺序;同档对齐不允许质量降档。 */ +export function selectSeasonRelease(spec: SlotSpec, versions: readonly QualityFacts[]): QualityFacts | undefined { + let target: QualityFacts | undefined; + const rank = (q: QualityFacts) => { + const tier = qualityTier(q, spec); + return [ + currentPri(spec, q), + tier.source, + tier.resolution, + ...softRank(spec, { raw: "", hdr: [], audio: [], ...q } as unknown as ParsedRelease), + ]; + }; + for (const q of versions) { + const key = seasonReleaseKey(q); + if (!key) continue; + const compared = target ? compareRank(rank(q), rank(target)) : 1; + if (compared > 0 || (compared === 0 && key < seasonReleaseKey(target!)!)) target = q; + } + return target; +} diff --git a/packages/domain/src/slot/types.ts b/packages/domain/src/slot/types.ts index 515beaa4..7b83a812 100644 --- a/packages/domain/src/slot/types.ts +++ b/packages/domain/src/slot/types.ts @@ -91,6 +91,10 @@ export interface SlotSoftPreferences { export interface SlotUpgradePolicy { enabled: boolean; + /** 洗版阶段显式规则组;未设时沿用既有洗版默认链。 */ + ruleGroupIds?: string[]; + /** 追更可混合,洗版按季向同组、同来源和编码的版本收敛。 */ + consistency?: "season_release"; /** 最小改进:来源等级至少提升几级,默认 1(同等级不反复更换) */ minTierSteps?: number; /** 来源等级表;默认 REMUX/原盘/蓝光二压=3,WEB-DL=2,WEBRip/HDTV/HDRip=1,DVD=0 */ @@ -128,6 +132,9 @@ export interface SlotSpec { hard: SlotHardConditions; /** 规则组:硬排除 → 拒绝;各组须命中某层(ladderRequired);pri 取最后一个带层的组(覆盖 soft.tiers 的 pri) */ rules?: SlotRuleChain; + /** 服务入口解析的洗版规则,不参与首次补齐。 */ + upgradeRules?: SlotRuleChain; + upgradeHard?: SlotHardConditions; unknownPolicy?: UnknownPolicy | Partial>; soft?: SlotSoftPreferences; upgrade?: SlotUpgradePolicy; @@ -151,6 +158,8 @@ export type QualityFacts = Pick< /** 评估上下文:站点字段与折算口径(不在标题解析里的事实由调用方给) */ export interface EvaluationContext { + /** 季级成套目标,由同一收录版本的有效资产及在途计划确定。 */ + seasonReleaseTarget?: QualityFacts; siteId?: string; /** 副标题/标签等参与关键词匹配的文本 */ subtitle?: string; diff --git a/packages/domain/test/collection-policy.test.ts b/packages/domain/test/collection-policy.test.ts new file mode 100644 index 00000000..b8d5f6c5 --- /dev/null +++ b/packages/domain/test/collection-policy.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateCandidate, + type ParsedRelease, + parseRelease, + parseRuleString, + type QualityFacts, + type RuleGroupSpec, + rankForSlot, + type SlotSpec, + seasonReleaseKey, + selectAcquisitionPolicy, + selectSeasonRelease, +} from "../src/index.ts"; + +const release = (title: string, facts: Partial = {}): ParsedRelease => ({ + ...parseRelease(title), + ...facts, +}); +const group = (name: string, rule: string): RuleGroupSpec => ({ name, layers: parseRuleString(rule).layers }); +const current: QualityFacts = release("Show.S01E01.1080p.WEB-DL.H265-OTHER"); +const target = release("Show.S01E02.1080p.WEB-DL.H265-CHD"); +const consistent: SlotSpec = { + name: "正片", + hard: { resolutions: ["1080p", "2160p"] }, + rules: { groups: [group("允许收录", "1080P | 4K")] }, + upgrade: { enabled: true, consistency: "season_release" }, +}; + +describe("按分类选择默认收录策略", () => { + const policies = [ + { id: "global", scope: {} }, + { id: "series", scope: { mediaKinds: ["series" as const] } }, + { id: "animation", scope: { categories: { tv: ["动漫剧集"] } } }, + ]; + + it("订阅显式指定的策略覆盖类型和分类范围", () => { + expect( + selectAcquisitionPolicy(policies, { explicitId: "series", mediaKind: "movie", category: "动画电影" })?.id, + ).toBe("series"); + }); + + it("显式指定的策略不存在时不悄悄回退", () => { + expect( + selectAcquisitionPolicy(policies, { explicitId: "missing", mediaKind: "series", category: "动漫剧集" }), + ).toBeUndefined(); + }); + + it("分类匹配优先于类型默认,类型默认优先于全局默认", () => { + expect(selectAcquisitionPolicy(policies, { mediaKind: "series", category: "动漫剧集" })?.id).toBe("animation"); + expect(selectAcquisitionPolicy(policies, { mediaKind: "series", category: "华语剧集" })?.id).toBe("series"); + expect(selectAcquisitionPolicy(policies, { mediaKind: "movie", category: "动画电影" })?.id).toBe("global"); + }); + + it.each(["movie", "series", "variety"] as const)("%s 不串用另一类型的同名分类策略", (mediaKind) => { + const scoped = [ + { id: "variety", scope: { mediaKinds: ["variety" as const], categories: { tv: ["同名分类"] } } }, + { id: "movie", scope: { categories: { movie: ["同名分类"] } } }, + { id: "series", scope: { mediaKinds: ["series" as const], categories: { tv: ["同名分类"] } } }, + ]; + expect(selectAcquisitionPolicy(scoped, { mediaKind, category: "同名分类" })?.id).toBe(mediaKind); + }); + + it("同一范围按传入的最新发布顺序取第一个", () => { + const latest = { id: "latest", scope: { categories: { tv: ["动漫剧集"] } } }; + const old = { id: "old", scope: { categories: { tv: ["动漫剧集"] } } }; + expect(selectAcquisitionPolicy([latest, old], { mediaKind: "series", category: "动漫剧集" })).toBe(latest); + expect(selectAcquisitionPolicy([old, latest], { mediaKind: "series", category: "动漫剧集" })).toBe(old); + }); + + it("分类未知时跳过分类限定策略,仍可使用类型默认", () => { + expect(selectAcquisitionPolicy(policies, { mediaKind: "series", category: null })?.id).toBe("series"); + }); + + it("类型或分类没有匹配且无默认时返回 undefined", () => { + const scoped = policies.slice(1); + expect(selectAcquisitionPolicy(scoped, { mediaKind: "movie", category: "动漫剧集" })).toBeUndefined(); + expect(selectAcquisitionPolicy([policies[2]!], { mediaKind: "series", category: null })).toBeUndefined(); + expect(selectAcquisitionPolicy([], { mediaKind: "variety", category: "综艺" })).toBeUndefined(); + }); + + it("空的分类列表仍按类型默认处理", () => { + const typeDefault = { id: "type", scope: { mediaKinds: ["series" as const], categories: { movie: [], tv: [] } } }; + expect(selectAcquisitionPolicy([policies[0]!, typeDefault], { mediaKind: "series", category: null })).toBe( + typeDefault, + ); + }); +}); + +describe("整季同套的规格与目标选择", () => { + it("发布组大小写、联合组顺序、HDR 顺序不产生新的版本身份", () => { + const first = { + ...target, + releaseGroup: { name: "CHD@FLUX", source: "suffix" as const }, + hdr: ["DV", "HDR10"] as const, + }; + const second = { + ...target, + releaseGroup: { name: "flux@chd", source: "known" as const }, + hdr: ["HDR10", "DV"] as const, + }; + expect(seasonReleaseKey({ ...first, hdr: [...first.hdr] })).toBe( + seasonReleaseKey({ ...second, hdr: [...second.hdr] }), + ); + }); + + it.each(["resolution", "source", "videoCodec", "releaseGroup"] as const)( + "缺少 %s 时不声称属于完整成套版本", + (field) => { + expect(seasonReleaseKey({ ...target, [field]: undefined })).toBeNull(); + }, + ); + + it("来源、分辨率、编码和剪辑不同分别是不同的成套版本", () => { + for (const change of [ + { source: "BluRay" as const }, + { resolution: "2160p" as const }, + { videoCodec: "H.264" as const }, + { cut: "Extended" as const }, + { hdr: ["DV" as const] }, + ]) { + expect(seasonReleaseKey({ ...target, ...change })).not.toBe(seasonReleaseKey(target)); + } + }); + + const unrestricted: SlotSpec = { name: "主版本", hard: {} }; + it.each([ + { + name: "来源等级优先于更高分辨率和软偏好", + spec: { ...unrestricted, soft: { groups: ["CHD"] } }, + weaker: release("Show.S01E01.2160p.WEB-DL.H265-CHD"), + better: release("Show.S01E02.1080p.BluRay.H264-OTHER"), + }, + { + name: "同来源时分辨率优先于软偏好", + spec: { ...unrestricted, soft: { groups: ["CHD"] } }, + weaker: target, + better: release("Show.S01E02.2160p.WEB-DL.H265-OTHER"), + }, + { + name: "同档规格按发布组软偏好排序", + spec: { ...unrestricted, soft: { groups: ["CHD", "OTHER"] } }, + weaker: release("Show.S01E01.1080p.WEB-DL.H265-OTHER"), + better: target, + }, + { + name: "同档规格按 HDR 软偏好排序", + spec: { ...unrestricted, soft: { hdr: ["DV" as const, "HDR10" as const] } }, + weaker: release("Show.S01E01.1080p.WEB-DL.HDR.H265-CHD"), + better: release("Show.S01E02.1080p.WEB-DL.DV.H265-CHD"), + }, + { + name: "同档规格按编码软偏好排序", + spec: { ...unrestricted, soft: { codecs: ["H.265" as const, "H.264" as const] } }, + weaker: release("Show.S01E01.1080p.WEB-DL.H264-CHD"), + better: target, + }, + ])("$name,成套选择与候选排序一致", ({ spec, weaker, better }) => { + expect(rankForSlot(spec, [weaker, better])[0]?.candidate).toBe(better); + expect(selectSeasonRelease(spec, [weaker, better])).toBe(better); + expect(selectSeasonRelease(spec, [better, weaker])).toBe(better); + }); + + it("组合优先级先于来源和分辨率等级", () => { + const spec: SlotSpec = { ...unrestricted, rules: { groups: [group("来源偏好", "WEBDL > BLURAY")] } }; + const blue = release("Show.S01E01.2160p.BluRay.H265-OTHER"); + expect(rankForSlot(spec, [blue, target])[0]?.candidate).toBe(target); + expect(selectSeasonRelease(spec, [blue, target])).toBe(target); + }); + + it("规格不完整的版本不参与成套选择", () => { + const incomplete = { ...target, videoCodec: undefined, pri: 1000 }; + expect(selectSeasonRelease(unrestricted, [incomplete, target])).toBe(target); + expect(selectSeasonRelease(unrestricted, [incomplete])).toBeUndefined(); + }); + + it("同档没有偏好时目标不随数据库返回顺序反复变化", () => { + const other = release("Show.S01E02.1080p.WEB-DL.H265-OTHER"); + expect(selectSeasonRelease(unrestricted, [target, other])).toBe(selectSeasonRelease(unrestricted, [other, target])); + }); +}); + +describe("追更和洗版分别使用规则", () => { + const phased: SlotSpec = { + name: "主版本", + hard: { resolutions: ["1080p", "2160p"] }, + rules: { groups: [group("追更", "WEBDL")] }, + upgradeRules: { groups: [group("洗版", "BLURAY > WEBDL")] }, + upgrade: { enabled: true }, + }; + const blue = release("Show.S01E02.1080p.BluRay.H265-CHD"); + + it("补齐缺集按追更规则,洗版规则不提前过滤", () => { + expect(evaluateCandidate(phased, target)).toMatchObject({ decision: "accept", pri: 100 }); + expect(evaluateCandidate(phased, blue)).toMatchObject({ + decision: "reject", + violations: [expect.objectContaining({ field: "rule", expected: "追更" })], + }); + }); + + it("已有当前资产时改用洗版规则,并按洗版规则重算当前档位", () => { + expect(evaluateCandidate(phased, blue, current)).toMatchObject({ decision: "upgrade", pri: 100, fromPri: 99 }); + expect(evaluateCandidate(phased, target, current)).toMatchObject({ + decision: "reject", + violations: [expect.objectContaining({ code: "not_higher_priority" })], + }); + }); + + it("洗版专用硬条件不阻止追更,但在已有资产时生效", () => { + const spec: SlotSpec = { ...phased, upgradeHard: { resolutions: ["2160p"] } }; + expect(evaluateCandidate(spec, target).decision).toBe("accept"); + expect(evaluateCandidate(spec, blue, current)).toMatchObject({ + decision: "reject", + violations: [expect.objectContaining({ field: "resolution", code: "not_allowed" })], + }); + }); + + it.each(["1080p", "2160p"] as const)("允许分辨率 %s 在追更和洗版阶段均可使用", (resolution) => { + const spec: SlotSpec = { name: "主版本", hard: phased.hard, upgrade: { enabled: true } }; + const candidate = release(`Show.S01E02.${resolution}.BluRay.H265-CHD`); + expect(evaluateCandidate(spec, candidate).decision).toBe("accept"); + expect(evaluateCandidate(spec, candidate, current).decision).toBe("upgrade"); + }); + + it.each(["720p", "4320p"] as const)("范围外的分辨率 %s 不因更优来源或整季对齐而放行", (resolution) => { + const candidate = release(`Show.S01E02.${resolution}.BluRay.H265-CHD`); + expect(evaluateCandidate(consistent, candidate).decision).toBe("reject"); + expect(evaluateCandidate(consistent, candidate, current, { seasonReleaseTarget: candidate })).toMatchObject({ + decision: "reject", + violations: expect.arrayContaining([expect.objectContaining({ field: "resolution", code: "not_allowed" })]), + }); + }); + + it("分辨率未知时保持待补充,不把未知当作允许范围", () => { + expect(evaluateCandidate(phased, { ...target, resolution: undefined })).toMatchObject({ + decision: "unknown_field", + missing: ["resolution"], + }); + }); + + it("组合条件的编码未知时,不能因 NOT 自动命中低档", () => { + const spec: SlotSpec = { name: "主版本", hard: {}, rules: { groups: [group("编码", "H265 > !H265")] } }; + expect(evaluateCandidate(spec, release("Show.S01E02.1080p.WEB-DL-CHD"))).toMatchObject({ + decision: "unknown_field", + missing: ["codec"], + }); + }); + + it("高档分辨率已明确不满足时,无关的未知编码不阻止已匹配的低档", () => { + const spec: SlotSpec = { + name: "主版本", + hard: {}, + rules: { groups: [group("组合档位", "4K & H265 > 1080P")] }, + }; + expect(evaluateCandidate(spec, release("Show.S01E02.1080p.WEB-DL-CHD"))).toMatchObject({ + decision: "accept", + pri: 99, + }); + }); +}); + +describe("整季同套的同档对齐不绕过升级限制", () => { + it("同档的不同发布组可以逐集对齐到选定的整季版本", () => { + expect(evaluateCandidate(consistent, target, current, { seasonReleaseTarget: target })).toMatchObject({ + decision: "upgrade", + pri: 100, + fromPri: 100, + reasons: expect.arrayContaining(["season_release_aligned"]), + }); + }); + + it.each([ + { name: "来源", source: "BluRay" as const, resolution: "1080p" as const }, + { name: "分辨率", source: "WEB-DL" as const, resolution: "2160p" as const }, + ])("同档对齐不能降低当前文件的$name", ({ source, resolution }) => { + expect( + evaluateCandidate(consistent, target, { ...current, source, resolution }, { seasonReleaseTarget: target }), + ).toMatchObject({ decision: "reject", violations: [expect.objectContaining({ code: "lower_tier" })] }); + }); + + it("当前文件已经同套时不重复整理或下载", () => { + expect(evaluateCandidate(consistent, target, target, { seasonReleaseTarget: target }).decision).toBe("reject"); + const normalizedCurrent = { ...target, releaseGroup: { name: "chd", source: "known" as const } }; + expect(evaluateCandidate(consistent, target, normalizedCurrent, { seasonReleaseTarget: target }).decision).toBe( + "reject", + ); + }); + + it("另一套同档资源不能替换已选定的当季目标", () => { + const other = release("Show.S01E02.1080p.WEB-DL.H265-FLUX"); + expect(evaluateCandidate(consistent, other, current, { seasonReleaseTarget: target })).toMatchObject({ + decision: "reject", + violations: [expect.objectContaining({ code: "different_season_release" })], + }); + }); + + it("关闭升级时,同档对齐也不能发起替换", () => { + const spec = { ...consistent, upgrade: { ...consistent.upgrade!, enabled: false } }; + expect(evaluateCandidate(spec, target, current, { seasonReleaseTarget: target })).toMatchObject({ + decision: "reject", + violations: [expect.objectContaining({ code: "upgrade_disabled" })], + }); + }); + + it("全集洗版不能因同档对齐而接受单集或播出日期资源", () => { + const spec: SlotSpec = { ...consistent, upgrade: { ...consistent.upgrade!, mode: "full_pack" } }; + for (const candidate of [target, { ...target, episodes: undefined, airDate: "20261013" }]) { + expect(evaluateCandidate(spec, candidate, current, { seasonReleaseTarget: candidate })).toMatchObject({ + decision: "reject", + violations: [expect.objectContaining({ code: "not_full_pack" })], + }); + } + const pack = release("Show.S01.1080p.WEB-DL.H265-CHD"); + expect(pack.episodes).toBeUndefined(); + expect(evaluateCandidate(spec, pack, current, { seasonReleaseTarget: pack }).decision).toBe("upgrade"); + }); + + it("不合格蓝光压制组不能因同档对齐绕过名单", () => { + const spec: SlotSpec = { + ...consistent, + upgrade: { ...consistent.upgrade!, encodeGroupAllowlist: ["CHD"] }, + }; + const old = release("Show.S01E01.1080p.BluRay.x265-OTHER"); + const unqualified = release("Show.S01E02.1080p.BluRay.x265-BADGRP"); + expect(unqualified.isEncode).toBe(true); + expect(evaluateCandidate(spec, unqualified, old, { seasonReleaseTarget: unqualified })).toMatchObject({ + decision: "reject", + violations: [expect.objectContaining({ code: "encode_not_qualified" })], + }); + const qualified = release("Show.S01E02.1080p.BluRay.x265-chd"); + expect(evaluateCandidate(spec, qualified, old, { seasonReleaseTarget: qualified }).decision).toBe("upgrade"); + }); + + it.each([ + { field: "resolution", missing: "resolution" }, + { field: "source", missing: "source" }, + { field: "videoCodec", missing: "codec" }, + { field: "releaseGroup", missing: "group" }, + ] as const)("候选缺少 $field 时不执行成套替换", ({ field, missing }) => { + const candidate = { ...target, [field]: undefined }; + expect(evaluateCandidate(consistent, candidate, current, { seasonReleaseTarget: target })).toMatchObject({ + decision: "unknown_field", + missing: expect.arrayContaining([missing]), + }); + }); + + it("季目标缺失或规格不完整时等待,不自行认定当前候选是目标", () => { + expect(evaluateCandidate(consistent, target, current)).toMatchObject({ + decision: "unknown_field", + missing: ["current_asset"], + }); + expect( + evaluateCandidate(consistent, target, current, { seasonReleaseTarget: { ...target, videoCodec: undefined } }), + ).toMatchObject({ decision: "unknown_field", missing: ["current_asset"] }); + }); +}); diff --git a/packages/domain/test/differential/filter.test.ts b/packages/domain/test/differential/filter.test.ts index a339f456..99282d2a 100644 --- a/packages/domain/test/differential/filter.test.ts +++ b/packages/domain/test/differential/filter.test.ts @@ -194,7 +194,7 @@ describe("MP 过滤规则差分(filter-cases.json)", () => { }); }); -/** 生成/更新登记时用:UMT_FILTER_DUMP=1 deno task test 打印当前不一致 */ +/** 生成/更新登记时用:UMT_FILTER_DUMP=1 bun run test 打印当前不一致 */ if (process.env.UMT_FILTER_DUMP) { const rows: Record = {}; for (const c of fx.cases) { diff --git a/packages/mapping/README.md b/packages/mapping/README.md index 1be5aa8d..f114da04 100644 --- a/packages/mapping/README.md +++ b/packages/mapping/README.md @@ -1,6 +1,6 @@ # @umt/mapping -domain ↔ contracts 的集中映射(W1 审查 B5)。只依赖 `@umt/domain` 与 `@umt/contracts`,不依赖 db / adapters。server 各模块需要在两套取值之间转换时一律走这里,不要在模块里各写一份。 +domain ↔ contracts 的语义映射。只依赖 `@umt/domain` 与 `@umt/contracts`,不依赖 db / adapters。server 各模块需要在两套取值之间转换时一律走这里,不要在模块里各写一份。 db 行(snake_case)↔ contracts(camelCase)的转换不在本包;adapters 的 `SiteRelease` 以结构类型 `SiteReleaseLike` 接入(兼容性由 `apps/server/test/mapping-compat.test.ts` 在类型层保证)。 @@ -10,12 +10,14 @@ db 行(snake_case)↔ contracts(camelCase)的转换不在本包;adapters 的 `Si | `evidence.ts` | `toContractEvidenceLevel` / `toDomainConfidence` / `toDomainMediaType` / `toContractMediaKind` / `toDomainEntityType` / `toContractEntityType` | `needs_confirm ↔ needs_review`,`manual → rule_confirmed`,`rejected → null`;`tv ↔ series/variety` | | `identity.ts` | `toContractResolveStatus` / `toReleaseResolutionStatus` / `rankedToIdentityCreate` / `localIdentityCandidate` | `auto/ambiguous/no_match → resolved|proposed/ambiguous/unidentifiable → source_release.resolution_status`;排名候选 → `ExternalIdentityCreate`;本地作品 → 本地优先候选 | | `slot.ts` | `toContractSlotStatus` / `toDomainSlotStatus` / `slotSpecFromRow` / `slotSpecToColumns` / `toContractSlotEvaluation` | Slot 状态;规则 JSON 列 ↔ `SlotSpec`;评估结果 → `{ decision, acceptable, rejected[] }` | -| `release.ts` | `toParsedColumns` / `fromParsedColumns` / `toContractContentType` / `toDomainContentType` / `toContractSegment` / `toDomainSegment` | `ParsedRelease ↔ source_release.parsed + parser_version`(版本不符返回 null,重解析) | +| `release.ts` | `toParsedColumns` / `fromParsedColumns` / `toContractSegment` / `toDomainSegment` | `ParsedRelease ↔ source_release.parsed + parser_version`(版本不符返回 null,重解析) | | `site-release.ts` | `siteReleaseToSourceRelease` / `sourceReleaseToSiteRelease` / `SiteReleaseLike` | 站点资源 ↔ `SourceReleaseCreate` / `SourceRelease`;downloadUrl 不入库 | | `path-binding.ts` | `toDomainPathBinding` / `toPathBindingCreate` / `parseTemplateVersion` | `entityId/scope/season/templateVersion:number/active ↔ mediaEntityId/bindingKind/seasonNumber/templateVersion:string/status` | | `organize.ts` | `organizePlanToRecord` / `organizePlanToFileResults` / `organizePlanToCapabilityOutput` / `organizationStatusFromFiles` / `toOrganizationAction` / `toOrganizeOperation` / `toFileRole` | `OrganizePlan →` organization_record / organization_file_result 草稿 / organize.plan 输出 | ## 规则 +- 同一语义的类型直接共用,不维护同值枚举的双向转换表。只在取值或含义存在实际差异时添加映射。 + - 映射表一律写成 `Record<一侧的联合类型, …>`:domain 增删取值时 typecheck 失败;contracts 侧在 `test/enums.test.ts` 按 `z.enum(...).options` 逐个断言,新增取值时测试失败。 - 新增映射先写测试(往返、schema 合法)。输出给 contracts 的对象都用对应 zod schema 在测试里 `safeParse` 一遍。 diff --git a/packages/mapping/deno.json b/packages/mapping/deno.json deleted file mode 100644 index 0a424da5..00000000 --- a/packages/mapping/deno.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@umt/mapping", - "version": "0.3.44", - "exports": { ".": "./src/index.ts" }, - "tasks": { - "test": "deno run -A npm:vitest run", - "typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json" - } -} diff --git a/packages/mapping/package.json b/packages/mapping/package.json index c3ac468e..1a9db450 100644 --- a/packages/mapping/package.json +++ b/packages/mapping/package.json @@ -4,5 +4,14 @@ "type": "module", "exports": { ".": "./src/index.ts" + }, + "version": "0.3.44", + "scripts": { + "test": "bun x --bun vitest run", + "typecheck": "bun x --bun tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@umt/contracts": "workspace:*", + "@umt/domain": "workspace:*" } } diff --git a/packages/mapping/src/release.ts b/packages/mapping/src/release.ts index 5a5d40c1..134853ac 100644 --- a/packages/mapping/src/release.ts +++ b/packages/mapping/src/release.ts @@ -1,10 +1,5 @@ -import type { ContentType as ContractContentType, Segment as ContractSegment, JsonObject } from "@umt/contracts"; -import { - type ContentType as DomainContentType, - type Segment as DomainSegment, - PARSER_VERSION, - type ParsedRelease, -} from "@umt/domain"; +import type { Segment as ContractSegment, JsonObject } from "@umt/contracts"; +import { type Segment as DomainSegment, PARSER_VERSION, type ParsedRelease } from "@umt/domain"; // 标题解析结果 ↔ source_release.parsed / parser_version 列;综艺内容类型与分段 ↔ content_unit 列。 @@ -28,29 +23,6 @@ export function fromParsedColumns(cols: { parsed: JsonObject; parserVersion: str return p as unknown as ParsedRelease; } -const CONTENT_TYPE: Record = { - unknown: "unknown", - main: "main", - extra: "extra", - behind_the_scenes: "behind_the_scenes", - performance_only: "performance_only", - preview: "preview", - reaction: "reaction", -}; -const CONTENT_TYPE_BACK: Record = { - main: "main", - extra: "extra", - behind_the_scenes: "behind_the_scenes", - performance_only: "performance_only", - preview: "preview", - reaction: "reaction", - unknown: "unknown", -}; -export const DOMAIN_CONTENT_TYPES = Object.keys(CONTENT_TYPE) as DomainContentType[]; - -export const toContractContentType = (t: DomainContentType): ContractContentType => CONTENT_TYPE[t]; -export const toDomainContentType = (t: ContractContentType): DomainContentType => CONTENT_TYPE_BACK[t]; - const SEGMENT: Record = { full: "full", upper: "upper", diff --git a/packages/mapping/test/enums.test.ts b/packages/mapping/test/enums.test.ts index eb812c79..1a32f577 100644 --- a/packages/mapping/test/enums.test.ts +++ b/packages/mapping/test/enums.test.ts @@ -117,15 +117,13 @@ describe("身份解析状态", () => { }); }); -describe("Slot / 内容类型 / 分段 / 整理动作", () => { +describe("Slot / 分段 / 整理动作", () => { it("Slot 状态两侧一一对应", () => { expect(sorted(M.DOMAIN_SLOT_STATUSES)).toEqual(sorted(C.SlotStatus.options)); for (const s of C.SlotStatus.options) expect(M.toContractSlotStatus(M.toDomainSlotStatus(s))).toBe(s); }); - it("综艺内容类型两侧一一对应;分段 unknown ↔ 缺失", () => { - expect(sorted(M.DOMAIN_CONTENT_TYPES)).toEqual(sorted(C.ContentType.options)); - for (const t of C.ContentType.options) expect(M.toContractContentType(M.toDomainContentType(t))).toBe(t); + it("分段 unknown ↔ 缺失", () => { expect(sorted([...M.DOMAIN_SEGMENTS, "unknown"])).toEqual(sorted(C.Segment.options)); for (const s of C.Segment.options) expect(M.toContractSegment(M.toDomainSegment(s))).toBe(s); }); diff --git a/packages/runtime/package.json b/packages/runtime/package.json new file mode 100644 index 00000000..b56c303d --- /dev/null +++ b/packages/runtime/package.json @@ -0,0 +1,15 @@ +{ + "name": "@umt/runtime", + "version": "0.3.44", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./worker": "./src/worker.ts" + }, + "scripts": { + "typecheck": "bun x --bun tsc --noEmit -p tsconfig.json", + "test": "bun x --bun vitest run" + }, + "dependencies": {} +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts new file mode 100644 index 00000000..c291b8b3 --- /dev/null +++ b/packages/runtime/src/index.ts @@ -0,0 +1,212 @@ +import { spawn } from "node:child_process"; +import { readFileSync, writeSync } from "node:fs"; +import { mkdir, open, opendir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { hostname } from "node:os"; +import { Readable, Writable } from "node:stream"; + +export const runtimeHostname = hostname; +export const runtimeReadTextFile = (path: string) => readFile(path, "utf8"); +export const runtimeReadTextFileSync = (path: string) => readFileSync(path, "utf8"); +export const runtimeWriteTextFile = (path: string, text: string) => writeFile(path, text); +export const runtimeMkdir = mkdir; +export const runtimeRename = rename; +export const runtimeRemove = rm; +export const runtimeReadDir = opendir; +export async function runtimeStat(path: string) { + const s = await stat(path); + return { size: s.size, isFile: s.isFile(), isDirectory: s.isDirectory() }; +} +export async function runtimeOpen( + path: string, + opts: { read?: boolean; write?: boolean; create?: boolean; truncate?: boolean }, +) { + const f = await open(path, opts.write ? (opts.truncate ? "w" : "a") : "r"); + return { + stat: async () => { + const s = await f.stat(); + return { size: s.size, isFile: s.isFile(), isDirectory: s.isDirectory() }; + }, + close: () => f.close(), + write: async (bytes: Uint8Array) => (await f.write(bytes)).bytesWritten, + get readable() { + return Readable.toWeb(f.createReadStream({ autoClose: true })) as unknown as ReadableStream; + }, + }; +} +export const runtimeWriteSync = (bytes: Uint8Array) => writeSync(1, bytes); +export const runtimeWrite = async (bytes: Uint8Array) => runtimeWriteSync(bytes); +export const runtimeUnrefTimer = (timer: unknown) => (timer as { unref?: () => void }).unref?.(); + +type CommandOptions = { + args?: string[]; + stdin?: "piped" | "null" | "inherit"; + stdout?: "piped" | "null" | "inherit"; + stderr?: "piped" | "null" | "inherit"; + env?: Record; + clearEnv?: boolean; + signal?: AbortSignal; +}; +export type ChildProcess = ReturnType; +export class Command { + constructor( + private cmd: string, + private opts: CommandOptions = {}, + ) {} + spawn() { + const o = this.opts; + const io = (s: string | undefined) => (s === "piped" ? "pipe" : s === "null" ? "ignore" : "inherit"); + const p = spawn(this.cmd, o.args ?? [], { + stdio: [io(o.stdin), io(o.stdout ?? "piped"), io(o.stderr ?? "piped")], + env: o.clearEnv ? o.env : { ...process.env, ...o.env }, + signal: o.signal, + }); + const status = new Promise<{ success: boolean; code: number; signal: string | null }>((resolve, reject) => { + p.once("error", reject); + p.once("close", (code, signal) => resolve({ success: code === 0, code: code ?? 1, signal })); + }); + // Attach a handler immediately: callers may still be writing stdin when spawn fails. + status.catch(() => {}); + const child = { + pid: p.pid!, + stdin: p.stdin ? Writable.toWeb(p.stdin) : new WritableStream(), + stdout: p.stdout + ? (Readable.toWeb(p.stdout) as unknown as ReadableStream) + : new ReadableStream({ + start(c) { + c.close(); + }, + }), + stderr: p.stderr + ? (Readable.toWeb(p.stderr) as unknown as ReadableStream) + : new ReadableStream({ + start(c) { + c.close(); + }, + }), + status, + kill: (signal: string = "SIGTERM") => { + p.kill(signal as NodeJS.Signals); + }, + unref: () => { + p.unref(); + }, + output: async () => { + const [s, stdout, stderr] = await Promise.all([ + status, + new Response(child.stdout).bytes(), + new Response(child.stderr).bytes(), + ]); + return { ...s, stdout, stderr }; + }, + }; + return child; + } + output() { + return this.spawn().output(); + } +} + +export interface NetAddr { + hostname: string; + port: number; +} +export interface HttpServer { + addr: NetAddr; + finished: Promise; + shutdown(): Promise; +} +type SocketData = { client: WebSocket }; +const upgrades = new WeakMap>(); +export function serve( + opts: { port: number; hostname?: string; signal?: AbortSignal; onListen?: (addr: NetAddr) => void }, + fetcher: (req: Request) => Response | Promise, +): HttpServer { + let finish!: () => void; + const finished = new Promise((resolve) => { + finish = resolve; + }); + const server = Bun.serve({ + port: opts.port, + hostname: opts.hostname, + async fetch(req, server) { + upgrades.set(req, server); + const response = await fetcher(req); + upgrades.delete(req); + return upgradedRequests.has(req) ? undefined : response; + }, + websocket: { + open(ws) { + const c = ws.data.client; + socketBindings.get(c)?.(ws); + socketBindings.delete(c); + Object.defineProperty(c, "readyState", { value: WebSocket.OPEN, configurable: true }); + c.dispatchEvent(new Event("open")); + }, + message(ws, data) { + ws.data.client.dispatchEvent( + new MessageEvent("message", { + data: + typeof data === "string" ? data : data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength), + }), + ); + }, + close(ws, code, reason) { + const c = ws.data.client; + Object.defineProperty(c, "readyState", { value: WebSocket.CLOSED, configurable: true }); + c.dispatchEvent(new CloseEvent("close", { code, reason })); + }, + }, + }); + const addr = { hostname: opts.hostname ?? "0.0.0.0", port: server.port! }; + opts.onListen?.(addr); + const shutdown = async () => { + await server.stop(true); + finish(); + }; + opts.signal?.addEventListener( + "abort", + () => { + void shutdown(); + }, + { once: true }, + ); + if (opts.signal?.aborted) void shutdown(); + return { addr, finished, shutdown }; +} +const upgradedRequests = new WeakSet(); +export function upgradeWebSocket(req: Request, opts: { protocol?: string } = {}) { + const server = upgrades.get(req); + if (!server) throw new Error("WebSocket upgrade requires a Bun HTTP server"); + // EventTarget facade preserves the bridge's browser WebSocket callbacks. + const client = new EventTarget() as WebSocket; + let ws: Bun.ServerWebSocket | undefined; + Object.defineProperty(client, "readyState", { value: WebSocket.CONNECTING, configurable: true }); + for (const event of ["open", "message", "close", "error"]) { + let callback: ((e: Event) => void) | null = null; + Object.defineProperty(client, `on${event}`, { + get: () => callback, + set: (fn) => { + if (callback) client.removeEventListener(event, callback); + callback = fn; + if (fn) client.addEventListener(event, fn); + }, + }); + } + client.send = (data) => { + ws?.send(data as string | ArrayBuffer); + }; + client.close = (code, reason) => { + ws?.close(code, reason); + }; + const data = { client }; + // Capture the native socket in open before dispatching the bridge callbacks. + socketBindings.set(client, (socket) => { + ws = socket; + }); + if (!server.upgrade(req, { data, headers: opts.protocol ? { "Sec-WebSocket-Protocol": opts.protocol } : undefined })) + throw new Error("WebSocket upgrade failed"); + const response = new Response(null); + upgradedRequests.add(req); + return { socket: client, response }; +} +const socketBindings = new WeakMap) => void>(); diff --git a/packages/runtime/src/worker.ts b/packages/runtime/src/worker.ts new file mode 100644 index 00000000..ac16a758 --- /dev/null +++ b/packages/runtime/src/worker.ts @@ -0,0 +1,262 @@ +import { Readable } from "node:stream"; +import { type ChildProcess, Command, runtimeWriteSync } from "./index.ts"; + +/** 本地执行协议。业务类型、重试、权限与超时策略由调用方负责。 */ +export interface WorkerRequest { + id: string; + task: string; + input: unknown; +} +export interface WorkerError { + code: string; + message: string; + detail?: Record; +} +export type WorkerReply = + | { id: string; type: "result"; result: unknown } + | { id: string; type: "error"; error: WorkerError }; +export type WorkerMessage = WorkerReply | { id: string; type: "progress"; value: unknown }; + +export class WorkerTransportError extends Error { + constructor( + readonly kind: "spawn" | "protocol" | "exit" | "size", + message: string, + override readonly cause?: unknown, + ) { + super(message); + this.name = "WorkerTransportError"; + } +} + +const encoder = new TextEncoder(); +export function encodeWorkerMessage(value: WorkerRequest | WorkerMessage, maxBytes: number): Uint8Array { + const bytes = encoder.encode(`${JSON.stringify(value)}\n`); + if (bytes.byteLength > maxBytes) throw new WorkerTransportError("size", `worker 消息超过 ${maxBytes} 字节`); + return bytes; +} + +/** 按字节限制一行的大小;UTF-8 与换行可以跨管道分块。EOF 不能代替消息结束符。 */ +export async function* workerLines( + reader: ReadableStreamDefaultReader, + maxBytes: number, +): AsyncGenerator { + const decoder = new TextDecoder("utf-8", { fatal: true }); + let parts: string[] = []; + let length = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + if (length) throw new WorkerTransportError("protocol", "worker 消息未以换行结束"); + return; + } + let start = 0; + for (let i = 0; i < value.length; i++) { + if (value[i] !== 10) continue; + length += i - start + 1; + if (length > maxBytes) throw new WorkerTransportError("size", `worker 消息超过 ${maxBytes} 字节`); + parts.push(decoder.decode(value.subarray(start, i), { stream: true }), decoder.decode()); + const line = parts.join(""); + parts = []; + length = 0; + start = i + 1; + if (!line) throw new WorkerTransportError("protocol", "worker 返回空消息"); + yield line; + } + length += value.length - start; + if (length > maxBytes) throw new WorkerTransportError("size", `worker 消息超过 ${maxBytes} 字节`); + parts.push(decoder.decode(value.subarray(start), { stream: true })); + } +} + +export async function readWorkerRequestText(maxBytes: number): Promise { + const stream = Readable.toWeb(process.stdin) as unknown as ReadableStream; + const reader = stream.getReader(); + try { + let text: string | undefined; + for await (const line of workerLines(reader, maxBytes)) { + if (text !== undefined) throw new WorkerTransportError("protocol", "一次性 worker 只能接收一个请求"); + text = line; + } + if (text === undefined) throw new WorkerTransportError("protocol", "worker 没有收到请求"); + return text; + } finally { + reader.releaseLock(); + } +} + +export function decodeWorkerRequest(text: string): WorkerRequest { + const req = JSON.parse(text) as WorkerRequest; + if (!req || typeof req.id !== "string" || !req.id || typeof req.task !== "string" || !("input" in req)) + throw new WorkerTransportError("protocol", "worker 请求格式无效"); + return req; +} + +export function writeWorkerMessage(message: WorkerMessage, maxBytes: number): void { + const bytes = encodeWorkerMessage(message, maxBytes); + let offset = 0; + while (offset < bytes.length) offset += runtimeWriteSync(bytes.subarray(offset)); +} + +function decodeMessage(line: string, id: string): WorkerMessage { + const value = JSON.parse(line) as WorkerMessage; + if (!value || value.id !== id) throw new WorkerTransportError("protocol", "worker 响应不属于当前请求"); + if (value.type === "result" && "result" in value) return value; + if (value.type === "progress" && "value" in value) return value; + if (value.type === "error" && typeof value.error?.code === "string" && typeof value.error?.message === "string") + return value; + throw new WorkerTransportError("protocol", "worker 响应格式无效"); +} + +/** 一个一次性进程连接。只管通信与回收,不重试、不决定超时、不安装权限策略。 */ +export class WorkerConnection { + readonly pid: number; + private readonly child: ChildProcess; + private readonly stdout: ReadableStreamDefaultReader; + private readonly stderrReader: ReadableStreamDefaultReader; + private reject: ((error: unknown) => void) | null = null; + private terminating: unknown; + private killTimer: ReturnType | undefined; + private settled = false; + private started = false; + private stderr = ""; + + constructor(command: { cmd: string; args: string[] }, env?: { values: Record; clear?: boolean }) { + try { + this.child = new Command(command.cmd, { + args: command.args, + stdin: "piped", + stdout: "piped", + stderr: "piped", + env: env?.values, + clearEnv: env?.clear, + }).spawn(); + } catch (error) { + throw new WorkerTransportError("spawn", "worker 子进程无法启动", error); + } + this.pid = this.child.pid; + this.stdout = this.child.stdout.getReader(); + this.stderrReader = this.child.stderr.getReader(); + } + + /** graceMs > 0 时先 SIGTERM 供文件操作清理;解析可直接 SIGKILL。等待始终有界。 */ + terminate(error: unknown, graceMs = 0, onForceKill?: () => void): void { + if (this.settled || this.terminating !== undefined) return; + this.terminating = error; + const force = () => { + try { + this.child.kill("SIGKILL"); + onForceKill?.(); + } catch { + // 已退出 + } + this.child.unref(); + this.fail(error); + }; + if (graceMs === 0) { + force(); + return; + } + try { + this.child.kill("SIGTERM"); + } catch { + // 已退出 + } + this.killTimer = setTimeout(force, graceMs); + } + + request( + req: WorkerRequest, + options: { maxRequestBytes: number; maxResponseBytes: number; onProgress?: (value: unknown) => void }, + ): Promise { + if (this.started) throw new Error("一次性 worker 连接不能重复请求"); + this.started = true; + if (this.terminating !== undefined) return Promise.reject(this.terminating); + if (!this.pid) + return this.child.status.then( + () => { + this.cleanup(); + throw new WorkerTransportError("exit", "worker 未启动"); + }, + (error) => { + this.cleanup(); + throw new WorkerTransportError("spawn", "worker 子进程无法启动", error); + }, + ); + return new Promise((resolve, reject) => { + this.reject = reject; + const errPump = this.readStderr(); + let final: WorkerReply | undefined; + const pump = (async () => { + for await (const line of workerLines(this.stdout, options.maxResponseBytes)) { + const msg = decodeMessage(line, req.id); + if (final) throw new WorkerTransportError("protocol", "worker 结果之后仍发送消息"); + if (msg.type === "progress") options.onProgress?.(msg.value); + else final = msg; + } + })(); + pump.catch((error) => this.terminate(this.transportError(error))); + this.child.status.then( + async (status) => { + try { + await Promise.all([pump, errPump]); + if (this.settled) return; + if (this.terminating !== undefined) return this.fail(this.terminating); + if (!status.success || !final) + throw new WorkerTransportError( + "exit", + `worker 异常退出(code=${status.code} signal=${status.signal}):${this.stderr}`, + ); + this.cleanup(); + resolve(final); + } catch (error) { + this.fail(this.terminating ?? this.transportError(error)); + } + }, + (error) => this.fail(new WorkerTransportError("spawn", "worker 子进程无法启动", error)), + ); + (async () => { + const bytes = encodeWorkerMessage(req, options.maxRequestBytes); + const writer = this.child.stdin.getWriter(); + try { + await writer.write(bytes); + await writer.close(); + } finally { + writer.releaseLock(); + } + })().catch((error) => this.terminate(this.transportError(error))); + if (this.terminating !== undefined) this.fail(this.terminating); + }); + } + + private transportError(error: unknown): WorkerTransportError { + return error instanceof WorkerTransportError + ? error + : new WorkerTransportError("protocol", `worker 通信失败:${(error as Error).message}`, error); + } + + private async readStderr(): Promise { + const decoder = new TextDecoder(); + try { + for (;;) { + const { done, value } = await this.stderrReader.read(); + if (done) return; + this.stderr = (this.stderr + decoder.decode(value, { stream: true })).slice(-2000); + } + } catch { + // 终止时管道可能断开 + } + } + + private fail(error: unknown): void { + if (this.settled) return; + this.cleanup(); + this.reject?.(error); + } + + private cleanup(): void { + this.settled = true; + clearTimeout(this.killTimer); + void this.stdout.cancel().catch(() => {}); + void this.stderrReader.cancel().catch(() => {}); + } +} diff --git a/packages/runtime/test/fixtures/worker.ts b/packages/runtime/test/fixtures/worker.ts new file mode 100644 index 00000000..2b9f14fe --- /dev/null +++ b/packages/runtime/test/fixtures/worker.ts @@ -0,0 +1,37 @@ +import { decodeWorkerRequest, readWorkerRequestText, writeWorkerMessage } from "../../src/worker.ts"; + +const req = decodeWorkerRequest(await readWorkerRequestText(4096)); +const send = (type: "result", result: unknown) => writeWorkerMessage({ id: req.id, type, result }, 4096); +switch (req.task) { + case "normal": + writeWorkerMessage({ id: req.id, type: "progress", value: { bytes: 2 } }, 4096); + send("result", req.input); + break; + case "large": + writeWorkerMessage({ id: req.id, type: "result", result: "中文".repeat(200_000) }, 2 * 1024 * 1024); + break; + case "wrong-id": + writeWorkerMessage({ id: "other", type: "result", result: true }, 4096); + break; + case "truncated": + process.stdout.write('{"id":"1"'); + break; + case "oversized": + process.stdout.write("x".repeat(8192)); + break; + case "after-result": + send("result", true); + send("result", false); + break; + case "failed-exit": + send("result", true); + process.exitCode = 1; + break; + case "hang": + setInterval(() => {}, 1000); + break; + case "stderr": + process.stderr.write("x".repeat(65536)); + send("result", true); + break; +} diff --git a/packages/runtime/test/worker.test.ts b/packages/runtime/test/worker.test.ts new file mode 100644 index 00000000..7694a487 --- /dev/null +++ b/packages/runtime/test/worker.test.ts @@ -0,0 +1,82 @@ +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { WorkerConnection, workerLines } from "../src/worker.ts"; + +const fixture = fileURLToPath(new URL("./fixtures/worker.ts", import.meta.url)); +const limits = { maxRequestBytes: 4096, maxResponseBytes: 4096 }; +function connection() { + return new WorkerConnection({ cmd: process.execPath, args: [fixture] }); +} +const request = (task: string) => ({ id: "1", task, input: "中文" }); + +describe("worker 管道与回收", () => { + it("拆开的 UTF-8 和多行消息仍能正确分帧", async () => { + const bytes = new TextEncoder().encode("中文\n第二行\n"); + const stream = new ReadableStream({ + start(c) { + for (const b of bytes) c.enqueue(Uint8Array.of(b)); + c.close(); + }, + }); + const lines = []; + for await (const line of workerLines(stream.getReader(), 64)) lines.push(line); + expect(lines).toEqual(["中文", "第二行"]); + }); + + it("转发进度、返回结果,并持续排空大量 stderr", async () => { + const seen: unknown[] = []; + expect(await connection().request(request("normal"), { ...limits, onProgress: (p) => seen.push(p) })).toEqual({ + id: "1", + type: "result", + result: "中文", + }); + expect(seen).toEqual([{ bytes: 2 }]); + expect(await connection().request(request("stderr"), limits)).toMatchObject({ result: true }); + }); + + it("大结果跨多个管道分块也完整返回", async () => { + const reply = await connection().request(request("large"), { ...limits, maxResponseBytes: 2 * 1024 * 1024 }); + expect(reply).toEqual({ id: "1", type: "result", result: "中文".repeat(200_000) }); + }); + + it.each([ + ["wrong-id", "protocol"], + ["truncated", "protocol"], + ["oversized", "size"], + ["after-result", "protocol"], + ["failed-exit", "exit"], + ])("拒绝 %s,而不接受部分或错误响应", async (task, kind) => { + await expect(connection().request(request(task), limits)).rejects.toMatchObject({ kind }); + }); + + it("请求大小超限也回收已启动的进程", async () => { + const c = connection(); + await expect(c.request({ ...request("hang"), input: "x".repeat(8192) }, limits)).rejects.toMatchObject({ + kind: "size", + }); + }); + + it("启动失败保留 spawn 分类", async () => { + const c = new WorkerConnection({ cmd: "/nonexistent/umt-worker", args: [] }); + await expect(c.request(request("normal"), limits)).rejects.toMatchObject({ kind: "spawn" }); + }); + + it("执行中的请求可终止,返回调用方的原因", async () => { + const c = connection(); + const pending = c.request(request("hang"), limits); + const reason = new Error("调用方取消"); + c.terminate(reason, 50); + await expect(pending).rejects.toBe(reason); + }); + + it("进度消费者抛错时回收连接,不让任务继续等待", async () => { + await expect( + connection().request(request("normal"), { + ...limits, + onProgress: () => { + throw new Error("进度无效"); + }, + }), + ).rejects.toMatchObject({ kind: "protocol", message: expect.stringContaining("进度无效") }); + }); +}); diff --git a/packages/runtime/tsconfig.json b/packages/runtime/tsconfig.json new file mode 100644 index 00000000..4ca11df4 --- /dev/null +++ b/packages/runtime/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "../../config/tsconfig.base.json", "include": ["src", "test"] } diff --git a/packages/tasks/README.md b/packages/tasks/README.md index 3b6fdbe5..72c3779f 100644 --- a/packages/tasks/README.md +++ b/packages/tasks/README.md @@ -13,7 +13,7 @@ | 导出 | 作用 | | --- | --- | | `OpenWorkflowEngine.connect({ databaseUrl, db, registry })` | `TaskEngine` 实现:`enqueue / signal / cancel / get / list / startWorker / stop` | -| `defineTask` / `TaskRegistry` / `createDefaultRegistry()` | 任务定义与注册表;默认注册表里有 §3.1-8 的 9 个任务(目前是空壳) | +| `defineTask` / `TaskRegistry` | 任务定义与注册表;实际业务 handler 由 server 所属模块注册,未注册任务抛 `UnknownTaskError` | | `OutboxRelay` | 用 `FOR UPDATE SKIP LOCKED` 认领 `outbox_event` 中 `status=pending` 且 `next_attempt_at` 已到的行,按 type 路由后 enqueue(幂等键取行的 `idempotency_key`,缺省 `outbox:`),成功标记 `dispatched` + `published_at`;失败 attempts+1 指数退避(上限 5 分钟),达 `maxAttempts` 置 `dead`;多进程并发时不会重复 | | `migrateEngineSchema(databaseUrl, schema?)` | 只跑引擎自己的迁移后断开(部署时 `--role migrate`) | | `withAttemptToken(db, opKey, fn, { commit })` | 操作账本 fencing,表是 `tasks_op_ledger` | @@ -24,9 +24,9 @@ - **取消**:`cancel(runId, reason)` 先写 `tasks_run_meta.cancel_requested_at`,再取消引擎里的 run。handler 执行期间会轮询这个标志(默认 1s)并触发 `abortSignal`;每个 `step.run/sleep` 开始前也会检查一次。进行中的步骤只能靠自己检查 `abortSignal` 来中止。已完成的外部动作不会撤销,补偿要写成显式步骤。 - **等待外部条件**:`signal(runId, name, payload)` 先把事实写进 `tasks_fact`,再发送引擎信号。`step.waitForFact(name, { check?, timeout })` 每一轮都先查库,查不到才带超时等待信号。所以事实比等待方先到也不会丢。审批、下载完成这类业务事实,先写业务表,再用 `check` 去读业务表。 -## 新增/填充任务(W2 规则) +## 新增业务任务 -1. 在 `src/definitions.ts` 里用 `defineTask({ name, version, inputSchema, retryPolicy, handler })` 定义任务。输入只放业务 id,handler 自己去读业务表。重试策略要显式写:外部网络类 5 次(1s→60s),文件类 3 次。 +1. 在 server 所属业务模块的 `registerTasks` 中用 `defineTask({ name, version, inputSchema, retryPolicy, handler })` 定义任务。输入只放业务 id,handler 自己去读业务表。显式声明 `lane` 与符合该业务副作用的重试策略;server 的任务分道表与回归同步更新。 2. **步骤体必须幂等**。worker 被 kill 后,正在执行的步骤会在新 worker 上**从头重跑**(最长要等约 30s 租约到期)。已完成的步骤不会重跑。 3. **有外部副作用的步骤都走 `withAttemptToken`**,opKey 按语义生成,例如 `submit:`: - 已经有结果时直接返回; @@ -57,4 +57,4 @@ ## 测试 -`deno task test`(或本包 `deno task test`)会连接开发库,每个测试 schema 先用 `@umt/db` 的 `migrateToLatest` 建全部业务表(`DATABASE_URL`,默认 `postgres://umt:umt@localhost:55433/umt`)。每个测试文件使用独立的 `tasks_test_<名>_` schema(并发跑多份也不冲突),测试前后都会清理;库不可达时跳过集成测试。覆盖范围:kill -9 后续跑(子进程 worker)、取消、幂等 enqueue、两个 relay 并发、事实先到达、fencing、分组 worker 只认领自己的任务。 +`bun run test`(或本包 `bun run test`)会连接开发库,每个测试 schema 先用 `@umt/db` 的 `migrateToLatest` 建全部业务表(`DATABASE_URL`,默认 `postgres://umt:umt@localhost:55433/umt`)。每个测试文件使用独立的 `tasks_test_<名>_` schema(并发跑多份也不冲突),测试前后都会清理;单包运行时库不可达会跳过集成测试;根目录 `bun run test:all` 先检查连接,不可达直接失败。覆盖范围:kill -9 后续跑(子进程 worker)、取消、幂等 enqueue、两个 relay 并发、事实先到达、fencing、分组 worker 只认领自己的任务。 diff --git a/packages/tasks/deno.json b/packages/tasks/deno.json deleted file mode 100644 index f5621dbe..00000000 --- a/packages/tasks/deno.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@umt/tasks", - "version": "0.3.44", - "exports": { ".": "./src/index.ts" }, - "tasks": { - "test": "deno run -A npm:vitest run", - "typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json" - } -} diff --git a/packages/tasks/package.json b/packages/tasks/package.json index 49e9b714..b319c3ec 100644 --- a/packages/tasks/package.json +++ b/packages/tasks/package.json @@ -4,5 +4,13 @@ "type": "module", "exports": { ".": "./src/index.ts" + }, + "version": "0.3.44", + "scripts": { + "test": "bun x --bun vitest run", + "typecheck": "bun x --bun tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@umt/db": "workspace:*" } } diff --git a/packages/tasks/src/definitions.ts b/packages/tasks/src/definitions.ts deleted file mode 100644 index 1cc1e0a2..00000000 --- a/packages/tasks/src/definitions.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { z } from "zod"; -import { defineTask, TaskRegistry } from "./registry.ts"; - -// 任务书 §3.1-8 的九类任务。W1 只落空壳:输入 schema + 回显 handler,W2 填充处理器。 -// 填充规则见 README「新增/填充任务」:副作用走 withAttemptToken,外部条件用 step.waitForFact, -// 业务状态写业务表,不要放进 handler 返回值。 - -const id = z.string().min(1); - -/** 空壳 handler:记录一条日志并回显输入 */ -function stub(name: string) { - return async (ctx: { input: I; logger: { info(msg: string, f?: Record): void } }) => { - // TODO(W2): 实现真实处理器 - ctx.logger.info(`${name}: stub handler (TODO W2)`); - return { todo: true as const, task: name, input: ctx.input }; - }; -} - -const NETWORK_RETRY = { - maximumAttempts: 5, - initialInterval: "1s", - backoffCoefficient: 2, - maximumInterval: "60s", -} as const; -const FILE_RETRY = { - maximumAttempts: 3, - initialInterval: "2s", - backoffCoefficient: 2, - maximumInterval: "60s", -} as const; -const DB_RETRY = { maximumAttempts: 3, initialInterval: "1s", backoffCoefficient: 2, maximumInterval: "30s" } as const; - -export const ingestFeed = defineTask({ - name: "ingestFeed", - version: 1, - lane: "network", - inputSchema: z.object({ feedSourceId: id }), - retryPolicy: NETWORK_RETRY, - handler: stub("ingestFeed"), -}); - -export const resolveObservation = defineTask({ - name: "resolveObservation", - version: 1, - lane: "network", - inputSchema: z.object({ observationId: id }), - retryPolicy: NETWORK_RETRY, - handler: stub("resolveObservation"), -}); - -export const evaluateAcquisition = defineTask({ - name: "evaluateAcquisition", - version: 1, - lane: "network", - inputSchema: z.object({ - subscriptionId: id.optional(), - acquisitionSlotId: id.optional(), - sourceReleaseId: id.optional(), - }), - retryPolicy: DB_RETRY, - handler: stub("evaluateAcquisition"), -}); - -export const submitDownload = defineTask({ - name: "submitDownload", - version: 1, - lane: "network", - inputSchema: z.object({ acquisitionPlanId: id }), - retryPolicy: NETWORK_RETRY, - handler: stub("submitDownload"), -}); - -export const verifyAndImport = defineTask({ - name: "verifyAndImport", - version: 1, - lane: "file", - inputSchema: z.object({ downloadTaskId: id }), - retryPolicy: FILE_RETRY, - handler: stub("verifyAndImport"), -}); - -export const refreshMetadata = defineTask({ - name: "refreshMetadata", - version: 1, - lane: "network", - inputSchema: z.object({ mediaEntityId: id }), - retryPolicy: NETWORK_RETRY, - handler: stub("refreshMetadata"), -}); - -export const reconcileLibrary = defineTask({ - name: "reconcileLibrary", - version: 1, - lane: "maintenance", - inputSchema: z.object({ libraryRootId: id.optional(), reconcileRunId: id.optional() }), - retryPolicy: FILE_RETRY, - handler: stub("reconcileLibrary"), -}); - -export const cleanupDownloadCache = defineTask({ - name: "cleanupDownloadCache", - version: 1, - lane: "file", - inputSchema: z.object({ downloaderInstanceId: id.optional() }), - retryPolicy: FILE_RETRY, - handler: stub("cleanupDownloadCache"), -}); - -export const resolveDecision = defineTask({ - name: "resolveDecision", - version: 1, - lane: "agent", - inputSchema: z.object({ decisionId: id }), - retryPolicy: DB_RETRY, - handler: stub("resolveDecision"), -}); - -export const TASK_NAMES = [ - "ingestFeed", - "resolveObservation", - "evaluateAcquisition", - "submitDownload", - "verifyAndImport", - "refreshMetadata", - "reconcileLibrary", - "cleanupDownloadCache", - "resolveDecision", -] as const; -export type TaskName = (typeof TASK_NAMES)[number]; - -/** v0.1 的默认注册表;W2 把真实 handler 换进来(同名同版本替换,或升版本并保留旧版本) */ -export function createDefaultRegistry(): TaskRegistry { - return new TaskRegistry().register( - ingestFeed, - resolveObservation, - evaluateAcquisition, - submitDownload, - verifyAndImport, - refreshMetadata, - reconcileLibrary, - cleanupDownloadCache, - resolveDecision, - ); -} diff --git a/packages/tasks/src/index.ts b/packages/tasks/src/index.ts index b8951889..02396b7f 100644 --- a/packages/tasks/src/index.ts +++ b/packages/tasks/src/index.ts @@ -1,20 +1,6 @@ // @umt/tasks 公开入口:TaskEngine 端口 + OpenWorkflow 实现、任务注册表、outbox 中继、操作账本 fencing export const PACKAGE_NAME = "@umt/tasks"; -export { - cleanupDownloadCache, - createDefaultRegistry, - evaluateAcquisition, - ingestFeed, - reconcileLibrary, - refreshMetadata, - resolveDecision, - resolveObservation, - submitDownload, - TASK_NAMES, - type TaskName, - verifyAndImport, -} from "./definitions.ts"; export { FencingError, TaskCanceledError, UnknownTaskError } from "./errors.ts"; export { consoleSink, diff --git a/packages/tasks/test/crash-recovery.test.ts b/packages/tasks/test/crash-recovery.test.ts index c5743ef0..25460920 100644 --- a/packages/tasks/test/crash-recovery.test.ts +++ b/packages/tasks/test/crash-recovery.test.ts @@ -22,7 +22,7 @@ const WORKER = fileURLToPath(new URL("./fixtures/worker.ts", import.meta.url)); const PKG_DIR = fileURLToPath(new URL("..", import.meta.url)); function startWorker(env: Record): Promise { - const child = spawn(process.execPath, ["run", "-A", WORKER], { + const child = spawn(process.execPath, [WORKER], { cwd: PKG_DIR, env: { ...process.env, SCHEMA: schemas.biz, OW_SCHEMA: schemas.ow, ...env }, stdio: ["ignore", "pipe", "pipe"], diff --git a/packages/tasks/test/registry.test.ts b/packages/tasks/test/registry.test.ts index 9c23f37a..c4ad0bd9 100644 --- a/packages/tasks/test/registry.test.ts +++ b/packages/tasks/test/registry.test.ts @@ -1,14 +1,13 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { createDefaultRegistry, defineTask, redact, TASK_NAMES, TaskRegistry, UnknownTaskError } from "../src/index.ts"; +import { defineTask, redact, TaskRegistry, UnknownTaskError } from "../src/index.ts"; describe("TaskRegistry", () => { - it("注册任务书 §3.1-8 的九个空壳任务", () => { - const reg = createDefaultRegistry(); - expect(reg.names()).toEqual([...TASK_NAMES].sort()); - for (const n of TASK_NAMES) expect(reg.latest(n).version).toBe(1); - expect(reg.has("ingestFeed")).toBe(true); - expect(reg.has("nope")).toBe(false); + it("注册表默认为空,未注册任务明确报错", () => { + const reg = new TaskRegistry(); + expect(reg.names()).toEqual([]); + expect(reg.has("submitDownload")).toBe(false); + expect(() => reg.latest("submitDownload")).toThrow(UnknownTaskError); }); it("latest 取最高版本;select 按 name@version / @version 过滤", () => { @@ -24,14 +23,6 @@ describe("TaskRegistry", () => { expect(() => reg.latest("zzz")).toThrow(UnknownTaskError); expect(() => mk("a", 0)).toThrow(/invalid task version/); }); - - it("空壳 handler 回显输入", async () => { - const def = createDefaultRegistry().latest("submitDownload"); - const input = def.inputSchema.parse({ acquisitionPlanId: "p1" }); - const out = await def.handler({ input, logger: { info() {} } } as never); - expect(out).toEqual({ todo: true, task: "submitDownload", input: { acquisitionPlanId: "p1" } }); - expect(() => def.inputSchema.parse({})).toThrow(); - }); }); describe("redact", () => { diff --git a/packages/testkit/deno.json b/packages/testkit/deno.json deleted file mode 100644 index a848c599..00000000 --- a/packages/testkit/deno.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@umt/testkit", - "version": "0.3.44", - "exports": { ".": "./src/index.ts" }, - "tasks": { - "test": "deno run -A npm:vitest run", - "typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json" - } -} diff --git a/packages/testkit/package.json b/packages/testkit/package.json index 153866e9..d393b9d5 100644 --- a/packages/testkit/package.json +++ b/packages/testkit/package.json @@ -4,5 +4,11 @@ "type": "module", "exports": { ".": "./src/index.ts" - } + }, + "version": "0.3.44", + "scripts": { + "test": "bun x --bun vitest run", + "typecheck": "bun x --bun tsc --noEmit -p tsconfig.json" + }, + "dependencies": {} } diff --git a/packages/testkit/src/sites/mock-fetch.ts b/packages/testkit/src/sites/mock-fetch.ts index 4861e7bd..5b738889 100644 --- a/packages/testkit/src/sites/mock-fetch.ts +++ b/packages/testkit/src/sites/mock-fetch.ts @@ -21,7 +21,7 @@ export interface MockFetchRequest { } export interface MockFetch { - fetch: typeof globalThis.fetch; + fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; requests: MockFetchRequest[]; /** 追加/覆盖路由 */ route(key: string, handler: MockRouteHandler): void; @@ -87,7 +87,7 @@ export function createMockFetch(routes: Record = {}): return new Response("not found", { status: 404 }); }; return { - fetch: fetchImpl as typeof globalThis.fetch, + fetch: fetchImpl as (input: RequestInfo | URL, init?: RequestInit) => Promise, requests, route(key, handler) { table.delete(key); diff --git a/scripts/alist-e2e.ts b/scripts/alist-e2e.ts index 30e9af9c..d4f66332 100644 --- a/scripts/alist-e2e.ts +++ b/scripts/alist-e2e.ts @@ -3,8 +3,8 @@ // → 移动 → 可选跨驱动复制 / 移动(Alist 异步任务)→ 下载回本地比对 sha256 → 不存在 ≠ 失败 → 清理。 // // 用法: -// deno run -A scripts/alist-e2e.ts --url http://127.0.0.1:5244 --token <令牌> --root /s1 [--root2 /s2] [--size-mb 8] -// deno run -A scripts/alist-e2e.ts --url ... --user admin --password <密码> --root /s1 +// bun scripts/alist-e2e.ts --url http://127.0.0.1:5244 --token <令牌> --root /s1 [--root2 /s2] [--size-mb 8] +// bun scripts/alist-e2e.ts --url ... --user admin --password <密码> --root /s1 // 也可用环境变量 UMT_ALIST_REAL_URL / _TOKEN / _USER / _PASSWORD / _ROOT / _ROOT2。退出码 0 = 全部通过。 import { createHash, randomBytes } from "node:crypto"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -14,7 +14,7 @@ import { parseArgs } from "node:util"; import { type AlistAuth, AlistClient, AlistError, redactAlistText } from "../packages/adapters/src/alist/index.ts"; const { values: a } = parseArgs({ - args: Deno.args, + args: process.argv.slice(2), options: { url: { type: "string" }, token: { type: "string" }, @@ -26,7 +26,7 @@ const { values: a } = parseArgs({ keep: { type: "boolean" }, }, }); -const env = (k: string) => Deno.env.get(`UMT_ALIST_REAL_${k}`); +const env = (k: string) => process.env[`UMT_ALIST_REAL_${k}`]; const url = a.url ?? env("URL"); const token = a.token ?? env("TOKEN"); const user = a.user ?? env("USER"); @@ -37,7 +37,7 @@ if (!url || !root || (!token && !user)) { console.error( "用法:--url <地址> (--token <令牌> | --user <用户> --password <密码>) --root <可写目录> [--root2 <另一驱动目录>]", ); - Deno.exit(2); + process.exit(2); } const auth: AlistAuth = token ? { kind: "token", token } @@ -137,4 +137,4 @@ if (!a.keep) { } await rm(dir, { recursive: true, force: true }); console.log(failed ? `\n${failed} 步失败` : "\n全部通过"); -Deno.exit(failed ? 1 : 0); +process.exit(failed ? 1 : 0); diff --git a/scripts/asset-spec-backfill.ts b/scripts/asset-spec-backfill.ts index e1c5643f..d4e2d1cb 100644 --- a/scripts/asset-spec-backfill.ts +++ b/scripts/asset-spec-backfill.ts @@ -1,10 +1,10 @@ // LIBV-1 回填已入库视频资产的版本规格(media_asset.delivery)。默认只读(dry-run),加 --apply 才改库。 // // 用法(DATABASE_URL 必须显式给出,脚本不带默认库): -// DATABASE_URL=… deno run -A scripts/asset-spec-backfill.ts # 看清单:哪些资产会更新、前后规格 -// DATABASE_URL=… deno run -A scripts/asset-spec-backfill.ts --entity <作品 id> # 只看一部作品 -// DATABASE_URL=… deno run -A scripts/asset-spec-backfill.ts --probe # 对缺探测结果的本地文件跑 ffprobe(只读文件) -// DATABASE_URL=… deno run -A scripts/asset-spec-backfill.ts --apply # 写库 +// DATABASE_URL=… bun scripts/asset-spec-backfill.ts # 看清单:哪些资产会更新、前后规格 +// DATABASE_URL=… bun scripts/asset-spec-backfill.ts --entity <作品 id> # 只看一部作品 +// DATABASE_URL=… bun scripts/asset-spec-backfill.ts --probe # 对缺探测结果的本地文件跑 ffprobe(只读文件) +// DATABASE_URL=… bun scripts/asset-spec-backfill.ts --apply # 写库 // --json 输出 JSON;--limit N 只处理前 N 个资产 // // 规格来源:库内文件名解析 + 已有 media_info.probe + 入库时记下的资源解析(media_info._umt.quality)。 @@ -14,20 +14,20 @@ import { ffprobe } from "@umt/adapters"; import { createDb } from "@umt/db"; import { backfillVersionSpecs } from "../apps/server/src/modules/acquisition-organize/services/version-backfill.ts"; -const argv = [...Deno.args]; +const argv = [...process.argv.slice(2)]; const APPLY = argv.includes("--apply"); if (APPLY && argv.includes("--dry-run")) { console.error("--apply 与 --dry-run 不能同时给出"); - Deno.exit(2); + process.exit(2); } const arg = (k: string) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : undefined; }; -const url = Deno.env.get("DATABASE_URL"); +const url = process.env.DATABASE_URL; if (!url) { console.error("需要 DATABASE_URL(脚本不带默认库)"); - Deno.exit(2); + process.exit(2); } const db = createDb({ connectionString: url, max: 2 }); try { diff --git a/scripts/bench/fs-bench.ts b/scripts/bench/fs-bench.ts index 9de1725a..70d01b3d 100644 --- a/scripts/bench/fs-bench.ts +++ b/scripts/bench/fs-bench.ts @@ -1,11 +1,12 @@ // 文件操作基准(不进 ci):跨卷 move 1 GB、全量哈希 1 GB、10 万文件扫描;输出耗时与 MB/s。 -// 用法:deno run -A scripts/bench/fs-bench.ts --base /opt/tmp-bench --other /dev/shm [--size-mb 1024] [--files 100000] +// 用法:bun scripts/bench/fs-bench.ts --base /opt/tmp-bench --other /dev/shm [--size-mb 1024] [--files 100000] // base:被测盘(源文件、扫描树所在);other:跨卷 move 的目标(必须与 base 不同设备) // 结果写进 docs/porting/moviepilot-rust-transfer.md「基准记录」。 import { randomBytes } from "node:crypto"; import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { parseArgs } from "node:util"; +import { Command, runtimeOpen } from "@umt/runtime"; import { crossDeviceMove, FsProxy, @@ -17,7 +18,7 @@ import { } from "../../packages/adapters/src/index.ts"; const { values } = parseArgs({ - args: Deno.args, + args: process.argv.slice(2), options: { base: { type: "string" }, other: { type: "string", default: "/dev/shm" }, @@ -27,7 +28,7 @@ const { values } = parseArgs({ }); if (!values.base) { console.error("需要 --base <被测目录>"); - Deno.exit(2); + process.exit(2); } const MB = 1024 * 1024; const sizeMb = Number(values["size-mb"]); @@ -50,7 +51,7 @@ async function timed(name: string, bytes: number | null, fn: () => Promise { - const r = await new Deno.Command("cp", { args: [cp, join(other, "cp.bin")] }).output(); + const r = await new Command("cp", { args: [cp, join(other, "cp.bin")] }).output(); if (!r.success) throw new Error("cp failed"); }); diff --git a/scripts/browser-verify/compose.yml b/scripts/browser-verify/compose.yml index db064e76..0a41c2a8 100644 --- a/scripts/browser-verify/compose.yml +++ b/scripts/browser-verify/compose.yml @@ -9,9 +9,9 @@ services: ports: - "127.0.0.1:49377:9377" mocknexus: - image: denoland/deno:2.9.7 + image: oven/bun:1.4.3 working_dir: /repo - command: ["run", "--no-config", "--no-lock", "-A", "scripts/browser-verify/mock-site.ts"] + command: ["run", "scripts/browser-verify/mock-site.ts"] volumes: # 相对路径按项目目录(第一个 -f 文件所在的仓库根)解析 - ./:/repo:ro diff --git a/scripts/browser-verify/cookie-real.ts b/scripts/browser-verify/cookie-real.ts index 7a46cfd1..f905eae8 100644 --- a/scripts/browser-verify/cookie-real.ts +++ b/scripts/browser-verify/cookie-real.ts @@ -4,7 +4,7 @@ * 只输出布尔值与个数:不截图、不输出页面内容与 Cookie 值;对真实站点只有一次导航。 * 用法(只读数据库;camofox 需带 umt-cookies 插件,见 docs/runbook/browser.md §9): * CAMOFOX_URL=http://127.0.0.1:49391 CAMOFOX_ACCESS_KEY=... DATABASE_URL=postgres://... SECRET_MASTER_KEY=... \ - * deno run -A scripts/browser-verify/cookie-real.ts + * bun scripts/browser-verify/cookie-real.ts * 密钥只从环境变量读,不写进文件。 */ import { @@ -20,11 +20,11 @@ import { createSecretStore } from "../../apps/server/src/modules/auth-settings/p import { readAccountCookie } from "../../apps/server/src/modules/sources-identity/service/credentials.ts"; const env = (k: string) => { - const v = Deno.env.get(k); + const v = process.env[k]; if (!v) throw new Error(`缺少环境变量 ${k}`); return v; }; -const siteKey = Deno.args[0] ?? "hhanclub"; +const siteKey = process.argv.slice(2)[0] ?? "hhanclub"; const db = createDb({ connectionString: env("DATABASE_URL"), max: 2 }); const secrets = createSecretStore({ db, masterKey: env("SECRET_MASTER_KEY") }); const quiet = { info() {}, warn() {}, error() {}, debug() {} } as never; diff --git a/scripts/browser-verify/run.sh b/scripts/browser-verify/run.sh index 0263fa7d..84971f17 100755 --- a/scripts/browser-verify/run.sh +++ b/scripts/browser-verify/run.sh @@ -11,7 +11,7 @@ export CAMOFOX_BROWSER_IDLE_TIMEOUT_MS=${CAMOFOX_BROWSER_IDLE_TIMEOUT_MS:-30000} export VERIFY_PG=postgres://umt:umtbr-verify@127.0.0.1:55447/umt export OUT_DIR=${OUT_DIR:-$(pwd)/.browser-verify-out} C="docker compose -p umtbr -f docker-compose.yml -f scripts/browser-verify/compose.yml" -V="deno run -A scripts/browser-verify/verify.ts" +V="bun scripts/browser-verify/verify.ts" wait_healthy() { for _ in $(seq 1 60); do [ "$(docker inspect -f '{{.State.Health.Status}}' umtbr-camofox-1 2>/dev/null)" = healthy ] && return 0 diff --git a/scripts/browser-verify/verify.ts b/scripts/browser-verify/verify.ts index 71d7bd02..969dd63c 100644 --- a/scripts/browser-verify/verify.ts +++ b/scripts/browser-verify/verify.ts @@ -34,7 +34,7 @@ import { AUDIENCES_FIXTURE_RELEASES } from "../../packages/testkit/src/sites/nex type Loose = any; const env = (k: string, d?: string) => { - const v = Deno.env.get(k) ?? d; + const v = process.env[k] ?? d; if (v === undefined) throw new Error(`缺少环境变量 ${k}`); return v; }; @@ -50,7 +50,7 @@ const EXPECTED_VERSION = env("VERIFY_CAMOFOX_VERSION", "1.18.0"); // SIGKILL 不执行 profile 落盘;新登录应明确识别为需登录,不能把它当作已恢复。 const KILL_LOGIN_REQUIRED = env("VERIFY_KILL_LOGIN_REQUIRED", "true") === "true"; const SCHEMA = "brverify"; -const phase = Deno.args[0] ?? "a"; +const phase = process.argv.slice(2)[0] ?? "a"; await mkdir(OUT, { recursive: true }); const results: Loose[] = []; @@ -621,4 +621,4 @@ try { await db.destroy(); await admin.destroy(); } -Deno.exit(results.length > 0 && results.every((r) => r.pass === true) ? 0 : 1); +process.exit(results.length > 0 && results.every((r) => r.pass === true) ? 0 : 1); diff --git a/scripts/browser-verify/vision-real.ts b/scripts/browser-verify/vision-real.ts index b5a7f6f1..72d9fbd5 100644 --- a/scripts/browser-verify/vision-real.ts +++ b/scripts/browser-verify/vision-real.ts @@ -2,7 +2,7 @@ * VIS-1 真机核验:对真实 camofox 打开 https://example.com/,截图 → 缩放成交给模型的图 → 在图上取链接坐标 → * 按服务端同一套换算(coordinateSpace / screenToPage)点回页面 → 核对跳转。记录视口 / DPR / 截图尺寸。 * 用法(只读用现有容器,不改部署): - * CAMOFOX_URL=http://:9377 CAMOFOX_ACCESS_KEY=... deno run -A scripts/browser-verify/vision-real.ts [out-dir] + * CAMOFOX_URL=http://:9377 CAMOFOX_ACCESS_KEY=... bun scripts/browser-verify/vision-real.ts [out-dir] * 访问密钥只从环境变量读,不写进文件。 */ import { mkdir, writeFile } from "node:fs/promises"; @@ -11,9 +11,9 @@ import { createCamofoxBrowserProvider, imageDimensions, prepareImage, staticBrow import type { NavigationBudget } from "@umt/contracts"; import { coordinateSpace, screenToPage } from "../../apps/server/src/modules/browser/service.ts"; -const baseUrl = Deno.env.get("CAMOFOX_URL") ?? "http://camofox:9377"; -const key = Deno.env.get("CAMOFOX_ACCESS_KEY") ?? ""; -const out = Deno.args[0] ?? ".browser-verify-out/vision"; +const baseUrl = process.env.CAMOFOX_URL ?? "http://camofox:9377"; +const key = process.env.CAMOFOX_ACCESS_KEY ?? ""; +const out = process.argv.slice(2)[0] ?? ".browser-verify-out/vision"; const PROVIDER = "00000000-0000-4000-8000-00000000f15a"; const budget: NavigationBudget = { domainAllowlist: ["example.com", "iana.org"], @@ -77,4 +77,4 @@ try { await p.closeSession(opened.session.id).catch(() => {}); } console.log(JSON.stringify(report, null, 2)); -if (!report.pass) Deno.exit(1); +if (!report.pass) process.exit(1); diff --git a/scripts/build-sandbox.sh b/scripts/build-sandbox.sh new file mode 100644 index 00000000..af1343cc --- /dev/null +++ b/scripts/build-sandbox.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")/.." +[ "$(uname -s)" = Linux ] || { echo '解析沙箱目前需要 Linux seccomp' >&2; exit 1; } +mkdir -p dist +cc -O2 -fPIC -shared -Wall -Wextra -Werror -Wno-unused-parameter \ + -I "${NODE_INCLUDE_DIR:-/usr/include/node}" \ + apps/server/src/sandbox/native/policy.c -o dist/sandbox-policy.node diff --git a/scripts/check-compiled-binary.sh b/scripts/check-compiled-binary.sh index 07d6651b..17229917 100755 --- a/scripts/check-compiled-binary.sh +++ b/scripts/check-compiled-binary.sh @@ -1,25 +1,30 @@ #!/bin/sh -# 编译产物自检(deno task ci 的最后一步):deno compile 的 --exclude-unused-npm 可能漏嵌 npm 包/子路径, -# 开发态与测试全绿但二进制启动即 ERR_MODULE_NOT_FOUND。这里用空环境跑 `--role preflight`: -# 它会先加载完整的服务端模块图(db/任务引擎/全部模块),然后才因缺配置以退出码 2 结束; -# 模块缺失会以退出码 1 + ERR_MODULE_NOT_FOUND 结束。 +# 编译产物必须在空环境、其它工作目录下加载完整模块图与解析沙箱。 +# preflight 缺配置以 2 退出;模块图加载失败以 1 退出。 set -u bin="${1:-dist/umt-server}" case "$bin" in /*) ;; *) bin="$PWD/$bin" ;; esac -[ -x "$bin" ] || { echo "check-compiled-binary: 找不到编译产物 $bin(先跑 deno task build)" >&2; exit 1; } +[ -x "$bin" ] || { echo "check-compiled-binary: 找不到编译产物 $bin(先跑 bun run build)" >&2; exit 1; } out=$(cd /tmp && env -i PATH="$PATH" "$bin" --role preflight 2>&1) code=$? if [ "$code" -eq 2 ] && printf '%s' "$out" | grep -q '配置无效'; then - # 沙箱子进程:与 sandbox/client.ts 同样的环境(只有 PATH)拉起 `--role sandbox`,请求一个不存在的任务, - # 应回一行 {"ok":false,...};入口就崩(如 --allow-run 解析失败)会没有这行 - sb=$(cd /tmp && printf '{"job":"__check__","input":null}' | env -i PATH="$PATH" "$bin" --role sandbox 2>&1) - if printf '%s' "$sb" | grep -q '"ok":false'; then - echo "check-compiled-binary: ok(模块图加载完整,沙箱子进程能启动)" - exit 0 + # 正常任务与拒绝策略都必须在空环境、/tmp 下成立。 + sb=$(cd /tmp && printf '{"id":"1","task":"self.test","input":{"mode":"ok"}}\n' | env -i PATH="$PATH" "$bin" --role sandbox 2>&1) + if ! printf '%s' "$sb" | grep -q '"type":"result"'; then + echo "check-compiled-binary: 沙箱子进程起不来:$sb" >&2 + exit 1 fi - echo "check-compiled-binary: 沙箱子进程起不来:" >&2 - printf '%s\n' "$sb" >&2 - exit 1 + for mode in io async-io write net exec; do + sb=$(cd /tmp && printf '{"id":"1","task":"self.test","input":{"mode":"%s"}}\n' "$mode" | env -i PATH="$PATH" "$bin" --role sandbox 2>&1) + # policy.lockdown 已直接核验 socket/openat/execve 返回 EPERM。 + # Bun 编译产物的网络异常可能被归一化成 ECONNREFUSED。 + if ! printf '%s' "$sb" | grep -q '"type":"error"' || ! printf '%s' "$sb" | grep -Eiq 'EPERM|operation not permitted|ECONNREFUSED'; then + echo "check-compiled-binary: 沙箱没有拒绝 $mode:$sb" >&2 + exit 1 + fi + done + echo "check-compiled-binary: ok(模块图加载完整,沙箱拒绝文件访问/网络/执行程序)" + exit 0 fi echo "check-compiled-binary: 编译产物自检失败(退出码 $code):" >&2 printf '%s\n' "$out" >&2 diff --git a/scripts/check-copy-punctuation.mjs b/scripts/check-copy-punctuation.mjs index 567f1e9b..91fce9f1 100644 --- a/scripts/check-copy-punctuation.mjs +++ b/scripts/check-copy-punctuation.mjs @@ -1,16 +1,16 @@ -#!/usr/bin/env -S deno run -A +#!/usr/bin/env -S bun // 文案标点与用词检查(规范见 docs/design/glossary.md「标点」与 .claude/skills/human-copy)。只看源码里的字符串字面量、模板字符串与 JSX 文本, // 不看注释、类型字面量、对象键、比较/匹配用的字符串(includes / split / === 等)和像正则的字符串。 // -// deno run -A scripts/check-copy-punctuation.mjs # 报告,warn 模式(退出码 0) -// deno run -A scripts/check-copy-punctuation.mjs --strict # 有违规退出码 1 -// deno run -A scripts/check-copy-punctuation.mjs --fix [文件…] # 自动改能确定的项(标点、省略号、括号、引号、空格) -// deno run -A scripts/check-copy-punctuation.mjs --by-file # 按文件汇总违规数 +// bun scripts/check-copy-punctuation.mjs # 报告,warn 模式(退出码 0) +// bun scripts/check-copy-punctuation.mjs --strict # 有违规退出码 1 +// bun scripts/check-copy-punctuation.mjs --fix [文件…] # 自动改能确定的项(标点、省略号、括号、引号、空格) +// bun scripts/check-copy-punctuation.mjs --by-file # 按文件汇总违规数 // // 例外:字符串所在行或上一行写 `copy-allow: 原因`;整个文件在前 5 行写 `copy-allow-file: 原因`。 import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; -import { parse } from "npm:@babel/parser@7.29.9"; +import { parse } from "@babel/parser"; const ROOT = new URL("..", import.meta.url).pathname; const SCOPES = ["apps/web/app", "apps/server/src", "packages/contracts/src"]; diff --git a/scripts/compile.ts b/scripts/compile.ts new file mode 100644 index 00000000..862ef644 --- /dev/null +++ b/scripts/compile.ts @@ -0,0 +1,44 @@ +import { cp, mkdir, readdir, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +process.chdir(root); +const native = Bun.spawnSync(["sh", "scripts/build-sandbox.sh"], { stdout: "inherit", stderr: "inherit" }); +if (native.exitCode !== 0) process.exit(native.exitCode); +await mkdir("dist", { recursive: true }); +const migrations = (await readdir("packages/db/migrations")).filter((name) => name.endsWith(".ts")).sort(); +const result = await Bun.build({ + entrypoints: ["apps/server/src/main.ts"], + compile: { outfile: "dist/umt-server", autoloadDotenv: false, autoloadBunfig: false }, + target: "bun", + plugins: [ + { + name: "embedded-migrations", + setup(build) { + build.onLoad({ filter: /packages\/db\/src\/migrator\.ts$/ }, async ({ path }) => { + const source = await readFile(path, "utf8"); + const imports = migrations + .map( + (name, i) => + `import * as migration${i} from ${JSON.stringify(resolve(root, "packages/db/migrations", name))};`, + ) + .join("\n"); + const entries = migrations + .map((name, i) => `${JSON.stringify(name.slice(0, -3))}: migration${i}`) + .join(",\n"); + return { + contents: `${imports}\n${source.replace("new FileMigrationProvider({ fs, path, migrationFolder: MIGRATIONS_DIR })", `{ getMigrations: async () => ({${entries}}) }`)}`, + loader: "ts", + }; + }); + }, + }, + ], +}); +if (!result.success) { + for (const log of result.logs) console.error(log); + process.exit(1); +} +await cp("packages/agent/skills", "dist/skills", { recursive: true, force: true }); +console.log("Built dist/umt-server, dist/sandbox-policy.node and dist/skills"); diff --git a/scripts/db-retention.ts b/scripts/db-retention.ts index d90774aa..70d996a6 100644 --- a/scripts/db-retention.ts +++ b/scripts/db-retention.ts @@ -3,9 +3,9 @@ // 默认只读(dry-run),输出每张表将删的行数;--apply 才删除(分批、每批单独事务,删得多的表随后 ANALYZE)。 // // 用法(DATABASE_URL 必须显式给出,脚本不带默认库): -// DATABASE_URL=postgres://umt:***@:5432/umt deno run -A scripts/db-retention.ts [--dry-run] # 看每张表将删多少行 -// DATABASE_URL=… deno run -A scripts/db-retention.ts --apply # 执行 -// DATABASE_URL=… deno run -A scripts/db-retention.ts --only task_run,pipeline [--apply] # 只处理部分目标 +// DATABASE_URL=postgres://umt:***@:5432/umt bun scripts/db-retention.ts [--dry-run] # 看每张表将删多少行 +// DATABASE_URL=… bun scripts/db-retention.ts --apply # 执行 +// DATABASE_URL=… bun scripts/db-retention.ts --only task_run,pipeline [--apply] # 只处理部分目标 // ENGINE_SCHEMA=openworkflow(缺省) // // 不包含:进程心跳(system.heartbeat_cleanup)、图片缓存与 kv_cache(cache 模块)、ANALYZE 任务(--apply 结束时对删过的表各自 ANALYZE)。 @@ -15,20 +15,20 @@ import { withStorageDefaults } from "@umt/contracts"; import { createDb, sql } from "@umt/db"; import { previewJob, RETENTION_JOBS } from "../apps/server/src/modules/system/retention.ts"; -const argv = [...Deno.args]; +const argv = [...process.argv.slice(2)]; const APPLY = argv.includes("--apply"); if (APPLY && argv.includes("--dry-run")) { console.error("--apply 与 --dry-run 不能同时给出"); - Deno.exit(2); + process.exit(2); } const onlyArg = argv.indexOf("--only"); const only = onlyArg >= 0 ? new Set((argv[onlyArg + 1] ?? "").split(",").filter(Boolean)) : null; -const url = Deno.env.get("DATABASE_URL"); +const url = process.env.DATABASE_URL; if (!url) { console.error("需要 DATABASE_URL(脚本不带默认库)"); - Deno.exit(2); + process.exit(2); } -const engineSchema = Deno.env.get("ENGINE_SCHEMA") ?? "openworkflow"; +const engineSchema = process.env.ENGINE_SCHEMA ?? "openworkflow"; const db = createDb({ connectionString: url, max: 2 }); try { diff --git a/scripts/dev-db-clean.ts b/scripts/dev-db-clean.ts index d83cf623..2aebc34d 100644 --- a/scripts/dev-db-clean.ts +++ b/scripts/dev-db-clean.ts @@ -1,9 +1,9 @@ // 开发库清理:只针对 55433 开发库(docker umt-dev-postgres-1),清掉测试残留。 // // 用法: -// deno run -A scripts/dev-db-clean.ts # 同 --dry-run:只打印清单,不改库 -// deno run -A scripts/dev-db-clean.ts --yes # 真正删除 -// DATABASE_URL=postgres://umt:umt@localhost:55433/umt deno run -A scripts/dev-db-clean.ts --dry-run +// bun scripts/dev-db-clean.ts # 同 --dry-run:只打印清单,不改库 +// bun scripts/dev-db-clean.ts --yes # 真正删除 +// DATABASE_URL=postgres://umt:umt@localhost:55433/umt bun scripts/dev-db-clean.ts --dry-run // // 清理范围: // 1. 测试 schema:名字匹配 TEST_SCHEMA_PATTERNS(各测试 harness 建的带 pid+随机后缀 schema)。 diff --git a/scripts/identity-cleanup.ts b/scripts/identity-cleanup.ts index 65599dad..685609a9 100644 --- a/scripts/identity-cleanup.ts +++ b/scripts/identity-cleanup.ts @@ -1,11 +1,11 @@ // 身份数据清理(FIX-5):删除冲突别名、合并重复的内容单元、删除孤立的临时内容单元。默认只读(dry-run),加 --apply 才改库。 // // 用法(DATABASE_URL 必须显式给出,脚本不带默认库): -// DATABASE_URL=postgres://umt:***@:5432/umt deno run -A scripts/identity-cleanup.ts [--dry-run] # 看清单(按类别列删除 / 保留) -// DATABASE_URL=… deno run -A scripts/identity-cleanup.ts --apply # 执行(单个事务) -// DATABASE_URL=… deno run -A scripts/identity-cleanup.ts --keep-orphans # 不删孤立单元 -// DATABASE_URL=… deno run -A scripts/identity-cleanup.ts --entity <作品 id> # 只看 / 只处理一部作品 -// DATABASE_URL=… deno run -A scripts/identity-cleanup.ts --merge-duplicates [--apply] # 只做第 2 步:合并重复单元(DH-1) +// DATABASE_URL=postgres://umt:***@:5432/umt bun scripts/identity-cleanup.ts [--dry-run] # 看清单(按类别列删除 / 保留) +// DATABASE_URL=… bun scripts/identity-cleanup.ts --apply # 执行(单个事务) +// DATABASE_URL=… bun scripts/identity-cleanup.ts --keep-orphans # 不删孤立单元 +// DATABASE_URL=… bun scripts/identity-cleanup.ts --entity <作品 id> # 只看 / 只处理一部作品 +// DATABASE_URL=… bun scripts/identity-cleanup.ts --merge-duplicates [--apply] # 只做第 2 步:合并重复单元(DH-1) // // 1. 冲突别名:只处理自动识别登记的站点别名(title_alias.source = 'site',作品级)。以下任一成立即删除: // - shared:同一归一化别名还挂在别的作品上,且它不是本作品自己的名字(主标题 / 原标题 / TMDB 译名与别名); @@ -32,20 +32,20 @@ import { type UnitMergeGroup, } from "../apps/server/src/modules/sources-identity/service/unit-merge.ts"; -const argv = [...Deno.args]; +const argv = [...process.argv.slice(2)]; const APPLY = argv.includes("--apply"); if (APPLY && argv.includes("--dry-run")) { console.error("--apply 与 --dry-run 不能同时给出"); - Deno.exit(2); + process.exit(2); } const MERGE_ONLY = argv.includes("--merge-duplicates"); const KEEP_ORPHANS = argv.includes("--keep-orphans") || MERGE_ONLY; const entityArg = argv.indexOf("--entity"); const ONLY_ENTITY = entityArg >= 0 ? (argv[entityArg + 1] ?? null) : null; -const url = Deno.env.get("DATABASE_URL"); +const url = process.env.DATABASE_URL; if (!url) { console.error("需要 DATABASE_URL(脚本不带默认库)"); - Deno.exit(2); + process.exit(2); } const db = new pg.Client({ connectionString: url }); await db.connect(); diff --git a/scripts/identity-reevaluate.ts b/scripts/identity-reevaluate.ts index d9673f69..b2ff5442 100644 --- a/scripts/identity-reevaluate.ts +++ b/scripts/identity-reevaluate.ts @@ -1,9 +1,9 @@ // 身份识别重评(ID-2):匹配规则更新后,处理存量的待识别事项与错误映射。默认只读(dry-run),加 --apply 才改库。 // // 用法(DATABASE_URL 必须显式给出,脚本不带默认库): -// DATABASE_URL=postgres://umt:***@:5432/umt deno run -A scripts/identity-reevaluate.ts -// DATABASE_URL=… deno run -A scripts/identity-reevaluate.ts --rollback-wrong # 另查分类词别名造成的错误映射 -// DATABASE_URL=… deno run -A scripts/identity-reevaluate.ts --rollback-wrong --apply # 真正执行 +// DATABASE_URL=postgres://umt:***@:5432/umt bun scripts/identity-reevaluate.ts +// DATABASE_URL=… bun scripts/identity-reevaluate.ts --rollback-wrong # 另查分类词别名造成的错误映射 +// DATABASE_URL=… bun scripts/identity-reevaluate.ts --rollback-wrong --apply # 真正执行 // // 1. open 的 identity_ambiguous:按新解析规则重新解析每个成员资源并给出预估(只用池里存的候选,不查 TMDB); // --apply 时把这些池条目的 next_review_at 置为现在、attempts 清零,由运行中的服务(已部署新规则)的池复查 @@ -25,13 +25,13 @@ import { } from "@umt/domain"; import pg from "pg"; -const args = new Set(Deno.args); +const args = new Set(process.argv.slice(2)); const APPLY = args.has("--apply"); const ROLLBACK = args.has("--rollback-wrong"); -const url = Deno.env.get("DATABASE_URL"); +const url = process.env.DATABASE_URL; if (!url) { console.error("需要 DATABASE_URL(脚本不带默认库)"); - Deno.exit(2); + process.exit(2); } const db = new pg.Client({ connectionString: url }); await db.connect(); diff --git a/scripts/mp-sync/README.md b/scripts/mp-sync/README.md index 832d17da..c7ca4a8e 100644 --- a/scripts/mp-sync/README.md +++ b/scripts/mp-sync/README.md @@ -1,6 +1,6 @@ # scripts/mp-sync -跟随上游移植工具(说明与对照总表:`docs/porting/mp-sync-ledger.md`)。用 `deno run -A` 运行(`yaml` 经根 deno.json import map 解析),不依赖 deno.json 任务。 +跟随上游移植工具(说明与对照总表:`docs/porting/mp-sync-ledger.md`)。用 `bun` 运行(`yaml` 经根 package.json 依赖解析),不依赖任务包装。 | 脚本 | 作用 | | --- | --- | @@ -9,9 +9,9 @@ | `movieclaw-import.mjs [--repo ] [--ref v0.27.0] [--check]` | MovieClaw v0.27.0(MIT)站点 YAML → `packages/adapters/src/sites/definitions/*.json` + `manifest.generated.ts`;覆盖层 `movieclaw-overrides/.json`;`--check` 只比较 | ```bash -deno run -A scripts/mp-sync/check.mjs --to <新提交> -deno run -A scripts/mp-sync/build-ledger.mjs --commit <新提交> -deno run -A scripts/mp-sync/movieclaw-import.mjs --check +bun scripts/mp-sync/check.mjs --to <新提交> +bun scripts/mp-sync/build-ledger.mjs --commit <新提交> +bun scripts/mp-sync/movieclaw-import.mjs --check ``` MovieClaw v0.27.0 之后为附条件许可:`movieclaw-import.mjs` 校验 ref→提交与 LICENSE=MIT,其他 ref 需 `--allow-ref` 并另行核对许可。 diff --git a/scripts/mp-sync/build-ledger.mjs b/scripts/mp-sync/build-ledger.mjs index 67af9f73..34017b8b 100755 --- a/scripts/mp-sync/build-ledger.mjs +++ b/scripts/mp-sync/build-ledger.mjs @@ -1,8 +1,8 @@ -#!/usr/bin/env -S deno run -A +#!/usr/bin/env -S bun /** * 生成 scripts/mp-sync/ledger.json 与 docs/porting/mp-sync-ledger.md 中的对照总表。 * - * deno run -A scripts/mp-sync/build-ledger.mjs [--mp /opt/moviepilot-plus/upstream/MoviePilot] [--commit e6820a8] + * bun scripts/mp-sync/build-ledger.mjs [--mp /opt/moviepilot-plus/upstream/MoviePilot] [--commit e6820a8] * * 数据来源:源码文件头 `@mp-source <上游路径> @ <提交>` 标签 + ledger.manual.json(未打标签的既有移植)。 * 每条记录保存移植时的上游提交与该文件内容 sha256,供 check.mjs 比较。 diff --git a/scripts/mp-sync/check.mjs b/scripts/mp-sync/check.mjs index 76ce6c19..34173be7 100755 --- a/scripts/mp-sync/check.mjs +++ b/scripts/mp-sync/check.mjs @@ -1,8 +1,8 @@ -#!/usr/bin/env -S deno run -A +#!/usr/bin/env -S bun /** * 比较 ledger.json 与 MoviePilot 目标提交,列出需要跟随移植的上游变化。 * - * deno run -A scripts/mp-sync/check.mjs [--mp ] [--to <目标提交,默认 HEAD>] [--json] + * bun scripts/mp-sync/check.mjs [--mp ] [--to <目标提交,默认 HEAD>] [--json] * * 输出:已变更 / 已删除 / 未变化 的 ledger 文件,监视目录(spider/、parser/)中 ledger 未收录的新文件, * 以及逐文件 `git diff` 命令。存在变化时退出码为 1(可用于 CI 或定时提醒)。 @@ -59,7 +59,7 @@ if (args.json) { console.log(`未变化:${unchanged.length}`); if (changed.length || deleted.length || added.length) { console.log( - "\n处理流程:按 diff 增量移植 → 把对应源码文件头 `@mp-source ... @ <提交>` 改为新提交(新文件新增标签)→ deno run -A scripts/mp-sync/build-ledger.mjs --commit <新提交> → 测试。", + "\n处理流程:按 diff 增量移植 → 把对应源码文件头 `@mp-source ... @ <提交>` 改为新提交(新文件新增标签)→ bun scripts/mp-sync/build-ledger.mjs --commit <新提交> → 测试。", ); } } diff --git a/scripts/mp-sync/gen-differential.py b/scripts/mp-sync/gen-differential.py index 12b0025a..13b876c5 100644 --- a/scripts/mp-sync/gen-differential.py +++ b/scripts/mp-sync/gen-differential.py @@ -13,7 +13,7 @@ meta:读 packages/domain/test/differential/meta-samples.json,分别以 accelerator=None(Python 参考实现)与 accelerator=rust(moviepilot_rust 快路径) 调用 MetaInfo / MetaInfoPath,写 meta-mp.json。两条路径不一致的样本记在 rustMismatch。 -生成后执行 `deno task fmt`(biome 统一 JSON 格式),再跑差分测试;新出现的差异需人工分类后 +生成后执行 `bun run fmt`(biome 统一 JSON 格式),再跑差分测试;新出现的差异需人工分类后 登记进对应 *-deviations.json / deviations 字段(kind 见 docs/porting/mp-rust-logic-parity.md)。 """ from __future__ import annotations @@ -354,7 +354,7 @@ def gen_filter_cmd() -> None: # parse_indexer_subtitles` 打桩为 None 强制走 PyQuery 回退;再恢复原函数跑一次记录 `mp_rust`)。 # MP 自带站点定义在加密资源 `user.sites.v3.bin`(Cython `SitesHelper`),未认证用户 `get_indexer()` 返回 {}, # `get_indexsites()` 只含 id/name/url,因此 indexer 配置改用本项目 `definitions/*.json`(同 MP schema)。 -# 输入先由 `deno run -A packages/adapters/test/differential/export-inputs.ts` 导出 tk-* fixture。 +# 输入先由 `bun packages/adapters/test/differential/export-inputs.ts` 导出 tk-* fixture。 UMT_ROOT = REPO DEFAULT_OUT = UMT_ROOT / "packages/adapters/test/differential" diff --git a/scripts/mp-sync/movieclaw-import.mjs b/scripts/mp-sync/movieclaw-import.mjs index cdc3e4e1..95150d11 100755 --- a/scripts/mp-sync/movieclaw-import.mjs +++ b/scripts/mp-sync/movieclaw-import.mjs @@ -1,9 +1,9 @@ -#!/usr/bin/env -S deno run -A +#!/usr/bin/env -S bun /** * MovieClaw v0.27.0(MIT)站点 YAML → 本项目 SiteDefinition JSON 转换器(可重复执行)。 * * 用法: - * deno run -A scripts/mp-sync/movieclaw-import.mjs [--repo spikes/movieclaw-src/repo] [--ref v0.27.0] [--check] + * bun scripts/mp-sync/movieclaw-import.mjs [--repo spikes/movieclaw-src/repo] [--ref v0.27.0] [--check] * * - 只用 `git show :` 读取文件(固定 tag,不读工作区,避免混入许可切换后的内容)。 * - 校验 解析到的提交与 LICENSE 为 MIT;ref 不是 v0.27.0 时要求 --allow-ref(许可需另行核对)。 diff --git a/scripts/overview-recompute.ts b/scripts/overview-recompute.ts index 6adc9ab2..af5f4c38 100644 --- a/scripts/overview-recompute.ts +++ b/scripts/overview-recompute.ts @@ -3,10 +3,10 @@ // 默认只读(dry-run),加 --apply 才改库(单个事务,写一条审计 metadata.overview_recompute)。 // // 用法(DATABASE_URL 必须显式给出,脚本不带默认库): -// DATABASE_URL=postgres://umt:***@:5432/umt deno run -A scripts/overview-recompute.ts # 看清单 -// DATABASE_URL=… deno run -A scripts/overview-recompute.ts --apply # 写库 -// DATABASE_URL=… deno run -A scripts/overview-recompute.ts --entity <作品 id> # 只看 / 只处理一部作品 -// DATABASE_URL=… deno run -A scripts/overview-recompute.ts --json # 清单输出 JSON +// DATABASE_URL=postgres://umt:***@:5432/umt bun scripts/overview-recompute.ts # 看清单 +// DATABASE_URL=… bun scripts/overview-recompute.ts --apply # 写库 +// DATABASE_URL=… bun scripts/overview-recompute.ts --entity <作品 id> # 只看 / 只处理一部作品 +// DATABASE_URL=… bun scripts/overview-recompute.ts --json # 清单输出 JSON // // 语言链与"简介是否允许回退到非中文"取库里的设置 metadata.languages / metadata.overviewNonChineseFallback。 // 规则与实现见 apps/server/src/modules/sources-identity/service/overview-recompute.ts、docs/runbook/metadata.md。 @@ -21,19 +21,19 @@ import { planOverviewRecompute, } from "../apps/server/src/modules/sources-identity/service/overview-recompute.ts"; -const argv = [...Deno.args]; +const argv = [...process.argv.slice(2)]; const APPLY = argv.includes("--apply"); const JSON_OUT = argv.includes("--json"); if (APPLY && argv.includes("--dry-run")) { console.error("--apply 与 --dry-run 不能同时给出"); - Deno.exit(2); + process.exit(2); } const entityArg = argv.indexOf("--entity"); const ONLY_ENTITY = entityArg >= 0 ? (argv[entityArg + 1] ?? null) : null; -const url = Deno.env.get("DATABASE_URL"); +const url = process.env.DATABASE_URL; if (!url) { console.error("需要 DATABASE_URL(脚本不带默认库)"); - Deno.exit(2); + process.exit(2); } const db = new Kysely({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: url, max: 2 }) }) }); diff --git a/scripts/release.sh b/scripts/release.sh index 3de070e0..b48b200a 100644 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -25,11 +25,11 @@ die() { echo "✗ $*" >&2; exit 1; } [ -f "$ENV_FILE" ] || die "缺少 $ENV_FILE" [ -z "$(git status --porcelain)" ] || die "工作区有未提交的改动,先提交再发布(git status)" -version=$(deno eval --quiet 'console.log(JSON.parse(Deno.readTextFileSync("deno.json")).version)') +version=$(bun -e 'console.log(JSON.parse(require("node:fs").readFileSync("package.json")).version)') tag="v$version" -for f in apps/*/deno.json apps/web/package.json packages/*/deno.json; do - v=$(deno eval --quiet "console.log(JSON.parse(Deno.readTextFileSync('$f')).version ?? '')") - [ -z "$v" ] || [ "$v" = "$version" ] || die "$f 版本 $v 与根 deno.json $version 不一致" +for f in apps/*/package.json packages/*/package.json; do + v=$(bun -e "console.log(JSON.parse(require("node:fs").readFileSync('$f')).version ?? '')") + [ -z "$v" ] || [ "$v" = "$version" ] || die "$f 版本 $v 与根 package.json $version 不一致" done grep -q "^## \[$version\]" CHANGELOG.md || die "CHANGELOG.md 还没有 ## [$version] 条目" diff --git a/scripts/test-all.sh b/scripts/test-all.sh index 396ac088..f5c4203d 100644 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # 全量测试:逐个包跑完(不在第一个失败处停),最后汇总;连不上测试库直接失败,不让数据库用例静默跳过。 # 用法:sh scripts/test-all.sh [包目录...] 缺省跑全部 workspace 成员 -# web 用 Node 跑 vitest(Deno 跑 web 的 vitest 有兼容问题)。 +# 全部包使用 Bun 运行 Vitest。 set -u cd "$(dirname "$0")/.." ROOT=$PWD @@ -10,17 +10,17 @@ if [ -f .env ]; then set -a; . ./.env; set +a; fi DB_URL=${DATABASE_URL:-postgres://umt:umt@localhost:55433/umt} if [ "${UMT_SKIP_DB_TESTS:-}" != "1" ]; then - if ! deno eval --quiet " - import pg from 'npm:pg@8.23.1'; - const c = new pg.Client({ connectionString: '$DB_URL', connectionTimeoutMillis: 3000 }); + if ! DATABASE_URL="$DB_URL" bun -e " + import pg from 'pg'; + const c = new pg.Client({ connectionString: process.env.DATABASE_URL, connectionTimeoutMillis: 3000 }); await c.connect(); await c.end();" >/dev/null 2>&1; then - echo "✗ 连不上测试库($DB_URL)。先 deno task dev:up;确实不跑数据库用例时设 UMT_SKIP_DB_TESTS=1" >&2 + echo "✗ 连不上测试库($DB_URL)。先 bun run dev:up;确实不跑数据库用例时设 UMT_SKIP_DB_TESTS=1" >&2 exit 2 fi fi members=("$@") -[ ${#members[@]} -eq 0 ] && members=(packages/adapters packages/agent packages/contracts packages/db packages/domain +[ ${#members[@]} -eq 0 ] && members=(packages/runtime packages/adapters packages/agent packages/contracts packages/db packages/domain packages/mapping packages/tasks packages/testkit apps/server apps/web) LOG_DIR=${TEST_LOG_DIR:-$ROOT/.local/test-logs} @@ -30,11 +30,7 @@ summary=() for m in "${members[@]}"; do log="$LOG_DIR/$(echo "$m" | tr / -).log" printf '▶ %-20s ' "$m" - if [ "$m" = apps/web ]; then - (cd "$m" && "$ROOT/node_modules/.bin/vitest" run) >"$log" 2>&1 - else - (cd "$m" && deno run -A npm:vitest run) >"$log" 2>&1 - fi + (cd "$m" && bun --bun "$ROOT/node_modules/vitest/vitest.mjs" run) >"$log" 2>&1 code=$? line=$(sed 's/\x1b\[[0-9;]*m//g' "$log" | grep -E '^\s+Tests ' | tail -1 | tr -s ' ') if [ $code -eq 0 ]; then echo "✓ ${line:-}"; else echo "✗ ${line:-退出码 $code}"; failed+=("$m"); fi diff --git a/scripts/tmdb-alias-language-cleanup.ts b/scripts/tmdb-alias-language-cleanup.ts index 4ab23f48..5612d028 100644 --- a/scripts/tmdb-alias-language-cleanup.ts +++ b/scripts/tmdb-alias-language-cleanup.ts @@ -3,10 +3,10 @@ // 默认只读(dry-run),加 --apply 才删(单个事务,写一条审计 metadata.tmdb_alias_language_cleanup)。 // // 用法(DATABASE_URL 必须显式给出,脚本不带默认库): -// DATABASE_URL=postgres://umt:***@:5432/umt deno run -A scripts/tmdb-alias-language-cleanup.ts # 看统计与样例 -// DATABASE_URL=… deno run -A scripts/tmdb-alias-language-cleanup.ts --apply # 删除 -// DATABASE_URL=… deno run -A scripts/tmdb-alias-language-cleanup.ts --entity <作品 id> # 只看 / 只处理一部作品 -// DATABASE_URL=… deno run -A scripts/tmdb-alias-language-cleanup.ts --samples 20 # 每类样例条数(缺省 5) +// DATABASE_URL=postgres://umt:***@:5432/umt bun scripts/tmdb-alias-language-cleanup.ts # 看统计与样例 +// DATABASE_URL=… bun scripts/tmdb-alias-language-cleanup.ts --apply # 删除 +// DATABASE_URL=… bun scripts/tmdb-alias-language-cleanup.ts --entity <作品 id> # 只看 / 只处理一部作品 +// DATABASE_URL=… bun scripts/tmdb-alias-language-cleanup.ts --samples 20 # 每类样例条数(缺省 5) // // 别名(title_alias)判定——只动 source = 'tmdb' 且未锁定的行;人工(user)/ 站点(site)/ 智能体(agent)/ NFO / 导入来源与 // 锁定的行一律不动: @@ -24,11 +24,11 @@ import { allowedLanguage, untaggedTextAllowed } from "@umt/adapters"; import { normalizeTitle } from "@umt/domain"; import pg from "pg"; -const argv = [...Deno.args]; +const argv = [...process.argv.slice(2)]; const APPLY = argv.includes("--apply"); if (APPLY && argv.includes("--dry-run")) { console.error("--apply 与 --dry-run 不能同时给出"); - Deno.exit(2); + process.exit(2); } const argOf = (k: string) => { const i = argv.indexOf(k); @@ -36,10 +36,10 @@ const argOf = (k: string) => { }; const ONLY_ENTITY = argOf("--entity"); const SAMPLES = Number(argOf("--samples") ?? 5) || 5; -const url = Deno.env.get("DATABASE_URL"); +const url = process.env.DATABASE_URL; if (!url) { console.error("需要 DATABASE_URL(脚本不带默认库)"); - Deno.exit(2); + process.exit(2); } const db = new pg.Client({ connectionString: url }); await db.connect(); @@ -179,7 +179,7 @@ if (!APPLY) { await db.query("rollback"); console.error("执行失败,已回滚:", (e as Error).message); await db.end(); - Deno.exit(1); + process.exit(1); } } else { console.log(`\n没有要删除的别名`);