docs(spikes): Deno 2.9.7 + TS 7 compatibility spike and ADR-004

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 18:24:08 +08:00
1 parent fe3613ee8f
commit cf85481d95
147 files changed
+15213

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
# ADR-004:运行时切换为 Deno 2.9.7(锁定)+ TypeScript 7.0.2
- 状态:提议(2026-09-27,Deno 准入 spike 完成后)。采纳后替代 ADR-001,ADR-001 的状态措辞由协调者修改。
- 关联:REQ §9、§10.2;plan §1;验证记录 `docs/spikes/deno-2.9.7.md`;代码 `spikes/deno/`
## 背景
用户要求运行时改成 Deno 最新稳定版并锁定版本,同时语言版本用 TypeScript 7.0.2;前端继续用 Vite 7 + React Router 7。ADR-001 当初选 Node 有三条理由:本机没有 Deno、依赖以 Node 为主要目标、工具链沿用 uhd_web。这次 spike 逐项核对了这三条:Deno 2.9.7 已装在 `/usr/local/bin/deno`;OpenWorkflow、Pi、Hono、Kysely/pg、pino、vitest、Vite/React Router 在 Deno 下的语义和 Node 一致;真实仓库 7 个包加 server 的测试和类型检查也都跑过。
## 决定
1. **运行时:Deno 2.9.7**,本机、CI、镜像三处锁同一版本。镜像用 `denoland/deno:2.9.7`,已确认有 amd64 和 arm64。依赖统一用 `deno install` + `deno.lock` 管理,彻底去掉 pnpm。
2. **语言版本:TypeScript 7.0.2**,由 `npm:[email protected]/tsc` 提供。它是 Go 原生实现,入口名就叫 `tsc`,包里没有 `tsgo`。它是 CI 的**类型门禁**。另外加一道 `deno check`,覆盖 Deno 专有的解析:import map、`jsr:`、`Deno.*` 全局。这一道同时也是 `deno compile` 实际使用的检查,它内置的 TS 是 6.0.3。
3. **依赖目录 `nodeModulesDir: "auto"`。** Vite、React Router、vitest、tsc 7 都要从 node_modules 解析依赖,所以必须保留这个目录。
4. **workspace 成员同时放 `deno.json` 和精简的 `package.json`。** deno.json 负责 name/exports/tasks;package.json 只写 name/type/exports,作用是让 `deno install` 把成员软链到 `node_modules/@umt/*`,这样 Vite、vitest、tsc 7 不需要配置 alias 或 paths 就能解析。npm 依赖的版本**只在根 `deno.json` 的 `imports` 里登记一次**,相当于统一目录。
5. **不改动的部分:** Hono 4(入口换成 `Deno.serve`)、Kysely 0.29 + pg 8(业务库)、postgres.js(只给 OpenWorkflow 用)、pino 10、vitest 5(通过 `deno run -A npm:vitest` 运行)、biome(通过 `npm:@biomejs/biome` 运行)、Vite 7 + React Router 7。
6. **生产形态:单容器,一个 `deno compile` 二进制。** 它按 `--role api|worker|all` 运行 API 和 worker,并托管 web 静态产物。镜像基础是 `debian:trixie-slim`,spike 实测 260 MB。
## 理由
- spike 的结论是通过或有变通,没有不通过的项。和 Node 的差异都能在工程层面处理:不带扩展名的 import、Kysely 迁移导入路径、TS7 移除了 `baseUrl`、compile 打包体积。
- 能拿回 Deno 的运行时权限模型,正好落实 ADR-001 列为代价的 REQ §10.2 要求。生产二进制在编译时就固定 `--allow-net/--allow-read=<白名单>/--allow-env/--allow-sys`,不给 `--allow-run`,除非后续确实需要 ffprobe 这类外部程序,那时按程序名列白名单。
- 测试和工具链继续沿用现有资产(vitest 用例、biome 规则、Vite 配置),迁移的改动主要是机械替换。
## 代价 / 风险
- 类型检查分两套:TS 7.0.2 和 Deno 内置的 6.0.3。只要不用 TS7 独有语法就不会有分歧,spike 里两套结果一致。
- `jsr:` 包 tsc 7 解析不了。规则是:业务代码只用 npm 发布的包;`@std/*` 只能出现在 `deno check` 覆盖、tsc 排除的文件里,目前没有这类文件。
- compile 默认会把整个 node_modules 嵌进二进制(440 MB),必须加 `--node-modules-dir=none --exclude-unused-npm`。代码里如果有动态 `import()`,要用 `--include` 显式带上。
- 有安装脚本的依赖默认不执行脚本。`protobufjs`、`@google/genai`、`msw` 被跳过后都不影响使用,和原来 pnpm 的 `allowBuilds` 结果一致。以后新增这类依赖时,要用 `deno approve-scripts` 显式批准。
## 迁移清单
### 1. 根目录
新增 `deno.json`,示意如下,版本照搬现有锁定:
```jsonc
{
"workspace": ["./packages/*", "./apps/server", "./apps/web"],
"nodeModulesDir": "auto",
"imports": {
"hono": "npm:[email protected]", "hono/": "npm:/[email protected]/",
"@hono/zod-openapi": "npm:@hono/[email protected]", "@hono/swagger-ui": "npm:@hono/[email protected]",
"kysely": "npm:[email protected]", "kysely/": "npm:/[email protected]/",
"pg": "npm:[email protected]", "@types/pg": "npm:@types/[email protected]", "postgres": "npm:[email protected]",
"openworkflow": "npm:[email protected]", "openworkflow/": "npm:/[email protected]/",
"@earendil-works/pi-agent-core": "npm:@earendil-works/[email protected]",
"@earendil-works/pi-agent-core/": "npm:/@earendil-works/[email protected]/",
"@earendil-works/pi-ai": "npm:@earendil-works/[email protected]", "typebox": "npm:[email protected]",
"zod": "npm:[email protected]", "pino": "npm:[email protected]", "pino-pretty": "npm:[email protected]",
"cheerio": "npm:cheerio@…", "fast-xml-parser": "npm:fast-xml-parser@…", "nunjucks": "npm:nunjucks@…",
"@xmldom/xmldom": "npm:@xmldom/xmldom@…", "xpath": "npm:xpath@…", "@types/nunjucks": "npm:@types/nunjucks@…",
"vitest": "npm:[email protected]", "typescript": "npm:[email protected]", "@types/node": "npm:@types/node@24",
"@biomejs/biome": "npm:@biomejs/[email protected]"
},
"compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true,
"noFallthroughCasesInSwitch": true, "verbatimModuleSyntax": true },
"tasks": {
"dev": { "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",
"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",
"types": "deno types > types/deno.d.ts",
"typecheck": "deno task types && deno task typecheck:tsc && deno task typecheck:deno",
"typecheck:tsc": "for p in packages/* apps/server; do deno run -A npm:[email protected]/tsc --noEmit -p $p/tsconfig.json || exit 1; done && cd apps/web && deno run -A npm:@react-router/[email protected]/react-router typegen && deno run -A npm:[email protected]/tsc -p tsconfig.json",
"typecheck:deno": "deno check apps/server/src/main.ts packages/*/src/index.ts",
"lint": "deno run -A npm:@biomejs/[email protected] check .",
"format": "deno run -A npm:@biomejs/[email protected] check --write .",
"test": "for p in packages/* apps/server apps/web; do (cd $p && deno run -A npm:[email protected] run) || exit 1; done",
"check:tokens": "cd apps/web && deno task check:tokens",
"build": "cd apps/web && deno task build && cd ../server && deno task compile",
"ci": "deno task typecheck && deno task lint && deno task test && deno task check:tokens && deno task build"
}
}
```
- lint/fmt:**保留 biome**(规则已调好,Deno 下验证可用),打开 `correctness/useImportExtensions` 来约束 import 必须带扩展名。`deno lint` 和 `deno fmt` 不纳入 CI,避免两套规则并存。
- 删除:`pnpm-workspace.yaml`、`pnpm-lock.yaml`、根 `package.json`、`tsx` 依赖、`apps/server/scripts/build.mjs`(esbuild,由 `deno compile` 取代)、`@hono/node-server`、`esbuild` devDependency。`.gitignore` 增加 `types/deno.d.ts`。
- `tsconfig.base.json`:保留(tsc 7 用),`types` 加一项 `"./types/deno.d.ts"`,或者只在 server 的配置里 include 它;其他选项在 TS7 下都已验证 0 错误。**不要**在根目录放 `tsconfig.json`,否则 `deno check` 会读它的 `lib`,导致 `Deno` 名字丢失。
- `docker-compose.dev.yml` 不变。
### 2. 每个成员
`packages/<x>/deno.json`:
```json
{ "name": "@umt/<x>", "version": "0.0.0", "exports": { ".": "./src/index.ts" } }
```
`packages/<x>/package.json` 精简为以下内容,去掉 dependencies、scripts、`workspace:*`:
```json
{ "name": "@umt/<x>", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } }
```
- 各包的 `tsconfig.json`(extends base)保留,用于 tsc 7。`vitest.config.ts` 保留,不用改。
- `apps/server/deno.json`:
- `tasks.dev`:`deno run -A --watch --env-file=../../.env src/main.ts`
- `tasks.start`:`deno run --allow-net --allow-env --allow-sys --allow-read --env-file=../../.env src/main.ts`
- `tasks.compile`:`deno compile --frozen --node-modules-dir=none --exclude-unused-npm --allow-net --allow-env --allow-sys --allow-read=/app --output ../../dist/umt-server src/main.ts`
- `apps/web`:**保留 package.json**。它的 scripts 由 `deno task` 直接执行,会用 Deno 运行 `node_modules/.bin`,不依赖 node。改动两处:`gen:tokens` / `check:tokens` 里的 `node` 换成 `deno run -A`;`tsconfig.json` 删掉 `"baseUrl": "."`(TS5102),`paths` 保持 `"~/*": ["./app/*"]` 不变。`@umt/contracts` 的依赖写成 `"workspace:*"`,Deno 认这个写法。
### 3. 代码改动
| 点 | 改法 |
|---|---|
| 不带扩展名的相对 import(src 和 test 共约 400 行) | 一次性执行 `deno lint --sloppy-imports --fix --rules-include=no-sloppy-imports packages apps/server`(已验证能自动改成 `./x.ts` / `./dir/index.ts`),之后由 biome `useImportExtensions` 守住 |
| `apps/server/src/main.ts` | `@hono/node-server` 的 `serve` 换成 `Deno.serve({ port, hostname, signal: ac.signal }, app.fetch)`。关服时执行 `ac.abort(); await server.finished`,`closeAllConnections` 可以删掉。实测挂着 SSE 长连接也能在 0.2 秒内退出。`package.json with { type: "json" }` 读版本号的写法改成读 `deno.json` |
| 静态托管(单容器) | `import { serveStatic } from "hono/deno"`。写法是 `app.use("/*", serveStatic({ root }))`,再加 `app.get("*", serveStatic({ root, path: "index.html" }))`。path 不能写绝对路径,否则 404 |
| Kysely 迁移 | 已经在用 `kysely/migration`,不变。import map 需要有 `"kysely/"` 前缀映射 |
| `packages/tasks/test/crash-recovery.test.ts` 和 agent 的崩溃恢复测试 | 原来是 `spawn(process.execPath, ["--import","tsx", WORKER])`,改成 `spawn(process.execPath, ["run","-A", WORKER])`,并去掉 tsx。现状在 Deno 下 tasks 已经 15/15;agent 失败的根因是不带扩展名的 import,做完上一行的修复就好 |
| `packages/db/scripts/*.ts` | 由 `tsx --env-file-if-exists` 改成 `deno run -A --env-file=...`(Deno 的 `--env-file` 在文件不存在时只告警) |
| logger | pino 保留。顺带修两个 Node 下也存在的脱敏漏洞:加 `formatters.bindings` 脱敏 child bindings;`redact()` 遇到 `Error` 先按 message/stack 序列化,否则 `err` 会变成 `{}` |
### 4. Node 专有 API 风险点(spike 中实测)
| API | Deno 2.9.7 现状 | 建议 |
|---|---|---|
| `worker_threads`(pino transport、`new Worker(url,{type:"module"})`) | 可用 | 新代码可以直接用 Web Worker(`new Worker(url, { type: "module" })`) |
| `process.on("SIGTERM")` / `process.exit` / `process.env` / `process.pid` | 可用,Deno 2 下 `process` 是全局对象 | 入口层统一改成 `Deno.addSignalListener`、`Deno.exit`、`Deno.env`,库代码保留 `node:process` 也行 |
| `fs.statfs`(磁盘余量) | 可用,bsize/bavail 正确 | 不变 |
| `child_process.spawn` / `kill(SIGKILL/SIGTERM)` | 可用 | 生产要 `--allow-run=<程序名>`。新代码可以用 `Deno.Command` |
| `process.execPath` | 是 deno 本身 | 凡是"用当前运行时起子进程"的地方,参数要改成 `run -A <file>`(见 §3) |
| `AsyncLocalStorage`、`node:crypto`、`node:util parseArgs`、`os.*` | 可用 | 不变 |
| `require` / `__dirname` | 仓库里只有 2 处 `__dirname`(脚本) | 改成 `import.meta.dirname` |
### 5. 测试
- **继续用 vitest 5**,运行方式是 `deno run -A npm:[email protected] run`,用例不用改。成员有精简 package.json 之后,`@umt/*` 走 node_modules 链接就能解析,不需要 alias。
- 暂不切 `deno test`。如果以后要切,最小改动是:import 换成 `@std/testing/bdd` 的 describe/it/beforeAll/afterAll 加 `@std/expect` 的 expect;`vi.fn/vi.spyOn` 换成 `@std/testing/mock` 的 `spy/stub`;`describe.skipIf(c)` 换成 `describe({ name, ignore: c, fn })`;同时要接受资源泄漏检查(sanitizer)。
### 6. Dockerfile(单容器,spike 已构建并验证)
```dockerfile
FROM denoland/deno:2.9.7 AS build
WORKDIR /app
COPY . .
RUN deno install --frozen
RUN deno task check:tokens && cd apps/web && deno task build
RUN cd apps/server && deno task compile # → /app/dist/umt-server
FROM debian:trixie-slim
RUN useradd -r -u 1000 umt
WORKDIR /app
COPY --from=build /app/dist/umt-server /app/umt-server
COPY --from=build /app/apps/web/build/client /app/public
ENV WEB_ROOT=/app/public HOST=0.0.0.0 PORT=4380
USER umt
EXPOSE 4380
ENTRYPOINT ["/app/umt-server"]
CMD ["--role", "all"]
```
- 不要用 `deno run` + `nodeModulesDir: auto` 当运行时入口。spike 实测运行时会尝试重建 node_modules 链接,报 EEXIST,镜像还有 813 MB。如果非要用 `deno run`,必须加 `--node-modules-dir=manual --cached-only`。
- 迁移作为同一个二进制的子命令(`--role migrate`),或者单独 compile 一个 `umt-migrate`,在部署时先执行。
### 7. CI
```bash
curl -fsSL https://deno.land/install.sh | sh -s v2.9.7 # 或 denoland/setup-deno,deno-version: 2.9.7
deno install --frozen
deno task ci # typecheck(tsc 7.0.2 + deno check)→ biome → vitest → tokens → web build + compile
```
需要 PG 的测试(tasks/db)在 CI 里单独起一个一次性 PG 服务,通过 `TASKS_TEST_DATABASE_URL` 注入。spike 显示,和开发者共用的 55433 库会互相干扰。
## 预估改动面
- 新增 9 个 `deno.json`,改 8 个 `package.json`,新增根 `deno.json`,删除 pnpm 相关的 3 个文件和 `build.mjs`。
- 约 400 行相对 import 补扩展名,可自动修复。server `main.ts` 约 30 行。2 个崩溃恢复测试的 spawn 参数。db scripts 3 个。logger 约 15 行。web 的 tsconfig 和 package.json 各 1 到 2 行。
- 文档:README、plan §1、ADR-001 标记 superseded。
- 业务逻辑(domain/contracts/adapters/agent/tasks 的实现)**不用改**。
+196
View File
@@ -0,0 +1,196 @@
# Deno 2.9.7 + TypeScript 7.0.2 切换准入验证记录
- 日期:2026-09-27;宿主:Linux x86_64,Docker 可用;Node v24.15.0 仍在机器上,用来做对照。
- 代码:`spikes/deno/`,是独立的 Deno workspace,不属于 pnpm workspace。原始输出在 `spikes/deno/logs/*.txt`。
- PG:`docker run -d --name umt-deno-pg -p 55435:5432 -e POSTGRES_PASSWORD=spike postgres:16-alpine`,验证结束后已删除。
- 结论见 `docs/adr/ADR-004-deno-runtime.md`。
## 0. 安装与版本
```text
$ curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s v2.9.7
Deno was installed successfully to /usr/local/bin/deno
$ deno --version
deno 2.9.7 (stable, release, x86_64-unknown-linux-gnu)
v8 15.0.245.2-rusty
typescript 6.0.3
```
**注意:Deno 2.9.7 内置的 TypeScript 是 6.0.3,不是 7。** `deno check`、`deno test`、`deno compile` 做类型检查时都用 6.0.3。TS 7.0.2 只能从 npm 装(`npm:[email protected]`),见 §g。
## 1. 结论表
| 项 | 结论 | 要点 |
|---|---|---|
| workspace + `nodeModulesDir: "auto"` + `npm:` imports + `deno install` | 通过 | 12 秒装完;生成的 `deno.lock` 是 v5 格式。有安装脚本的包(protobufjs、@google/genai、msw)默认被跳过,和 pnpm `allowBuilds` 的效果一样 |
| a. openworkflow 0.10.1 + postgres.js | 通过 | 迁移建表、step.run/sleep/waitForSignal、幂等键、kill -9 后被另一个 worker 接管、双 worker 40 个 run 无重复,语义和 Node 下一致 |
| b. pi-agent-core / pi-ai 0.87.1 | 通过 | fake provider 事件流、白名单(`bash` 被拒)、abort、SIGKILL 后 JSONL 恢复("结果未知",不重复提交)都跑通。`NodeExecutionEnv` 在 Deno 下可以用 |
| c1. Hono 4 + `Deno.serve` + SSE | 通过 | SSE 头和分帧正确;SIGTERM 触发 `AbortController` 后,即使有 SSE 长连接挂着,服务也在约 0.2 秒内退出 |
| c2. Kysely 0.29 + pg 8 / postgres.js | 两种都通过,**推荐 pg** | 项目真实的 12 个迁移在 Deno 下全部成功。postgres.js 方言对 `JSON.stringify` 过的 jsonb 会双重编码,所以业务库继续用 pg;OpenWorkflow 自己带 postgres.js |
| d. pino 10 | 通过 | 自定义 destination、formatters/hooks 脱敏、`pino.transport`(worker_threads)、pino-pretty 都能用,**不用换**。LogTape 作为备选也验证通过 |
| e. vitest 5 | 通过(需两处调整) | `deno run -A npm:vitest run` 能跑 node:http、child_process kill、fs 临时目录、statfs。真实仓库各包:server 15/15,contracts、db、domain、testkit 和 Node 一样;tasks 用干净 PG 跑 15/15;agent 有 1 例失败,原因是 import 没写扩展名 |
| e'. `deno test` + `@std/expect` | 通过 | 只改 2 行 import 就能跑同一套用例 |
| f. 前端 Vite 7 + React Router 7 | 通过 | `deno task build`(执行 package.json 里的 script)能构建,`vite dev` / `react-router dev` 在 5181 端口 curl 200;token 脚本 `deno run -A` 能生成也能检查。全程没有调用 node(用假 node 放在 PATH 最前面验证过) |
| g. TypeScript 7.0.2 | 通过(需变通) | 可执行入口是 `npm:[email protected]/tsc`,**包里没有 `tsgo`**。真实仓库 8 个 tsconfig 在 TS7 下都是 0 错误;web 要删掉 `baseUrl`(TS7 已移除这个选项)。tsc 解析不了 `jsr:`,Deno 全局类型要靠 `deno types` 提供 |
| h. `deno compile` / 镜像 | 通过(需变通) | 默认打包会把整个 node_modules 塞进去,二进制 440 MB;加 `--node-modules-dir=none --exclude-unused-npm` 后是 107 MB。`denoland/deno:2.9.7` 有 amd64 和 arm64 两个平台。单容器镜像 260 MB,API、SSE、SPA 回退和 SIGTERM 退出都验证过 |
## 2. 分项记录
### workspace 结构
```text
spikes/deno/
deno.json workspace: packages/x, packages/contracts, apps/server, web;nodeModulesDir: auto;imports 用 npm:/jsr:
packages/x/ deno.json 成员(name/exports),模拟 packages/*
packages/contracts/ 仓库 contracts 的快照;同时有 deno.json 和精简的 package.json
apps/server/ deno.json 成员:ow/、pi/、http.ts、db.ts、log-*.ts、node-compat.ts、test/
web/ apps/web 的只读复制;package.json 成员
tsconfig.tsc.json 只给 TS 7 用(不能叫 tsconfig.json,见 §g)
Dockerfile 单容器验证
```
**成员被 Vite/vitest/tsc 解析的前提:** `package.json` 成员会被 `deno install` 软链到 `node_modules/@umt/*`,所以 Vite、vitest、tsc 这些走 node 解析的工具都能找到它。只有 `deno.json` 的成员(比如 `@spike/x`)**不会**被链接,这些工具找不到。已验证同一个目录可以同时放 `deno.json` 和 `package.json`:Deno 的 name/exports 以 deno.json 为准,链接照样生成。
### a. OpenWorkflow
```text
$ deno run -A src/ow/cli.ts tables
biz.side_effect_ledger / openworkflow.openworkflow_migrations / step_attempts / workflow_runs / workflow_signals
# 正常路径:signal 发出后 run 完成,步骤依次是 submit→signal-wait→copy→register→sleep→cleanup;用同一个 idempotencyKey 再投递,返回的是同一个 run
# kill -9:copy-file(6s)执行中 kill -9 worker A,启动 worker B,约 36 秒后完成(租约过期接管)
1fdbe2ec copy-file A start
1fdbe2ec copy-file B start / end → register B → cleanup B (attempts=4)
# 双 worker:40 个 burst run
{"total":"40","runs":"40","a":"24","b":"16"}
# SIGTERM:Deno.addSignalListener → worker.stop() → "[A] worker stopped gracefully"
```
改动只有三处:`process.on("SIGTERM")` 换成 `Deno.addSignalListener`,`node:fs` 写 pidfile 换成 `Deno.writeTextFileSync`,其余文件在顶部补 `import process from "node:process"`。其实不改也能跑,因为 Deno 2 对 npm/Node 代码提供了全局 `process`,`process.on` 也验证可用,见 §h。
### b. Pi
```text
$ deno run -A src/pi/fake-run.ts
[tool_end] inventory__query isError=false → {"inLibrary":false}
[tool_end] download__submit isError=false → {"accepted":true,"idempotencyKey":"dl:Dune:1080p"}
[tool_end] bash isError=true -> "Tool \"bash\" is unavailable"
[run_end] status=completed fake calls: 3
$ SUBMIT_DELAY_MS=2000 deno run -A src/pi/abort-resume.ts crash # 进程自己 SIGKILL
$ deno run -A src/pi/abort-resume.ts resume
open operations at attach: [{"lane":"main","kind":"run",...}]
[tool_end] (recovery) download__submit "[Tool execution was interrupted ... external outcome is unknown.]"
SUBMITTED in this process [] ← 没有重复提交
```
### c. Hono + Deno.serve + SSE,Kysely
- `Deno.serve({ port, signal })` 直接接 `app.fetch`;`streamSSE` 输出 `event/data/id`。SIGTERM 时 abort signal,`server.finished` 会 resolve。
- 挂着 SSE 长连接时关服:不管应用层有没有主动关 SSE,进程都在约 0.21 秒内退出(`logs/hono-shutdown.txt`)。Node 那边原来需要 `closeAllConnections()`,这里不需要。
- 静态托管:用 `hono/deno` 的 `serveStatic({ root })`,SPA 回退要写成 `serveStatic({ root, path: "index.html" })`。**写绝对路径 `path: "/app/public/index.html"` 会 404**。
- Kysely 0.29 的迁移 API 要从 `kysely/migration` 导入,import map 里需要加 `"kysely/": "npm:/kysely@^0.29.6/"`。
```text
pg migrate: ["0001_init:Success","0002_index:Success"] rows: string:1/Dune/{"q":"1080p"}/true 50 并发/池 4: 158ms
postgresjs migrate: 同上 rows: string:1/Dune/"{\"q\":\"1080p\"}"/true ← jsonb 被双重编码 170ms
# 真实迁移(只读引用 packages/db/migrations):
pg migrate: ["0001_init:Success", … ,"0012_organization:Success"] error: null
```
### d. 日志
```text
{"level":30,...,"token":"[REDACTED]","url":"https://x/rss?passkey=[REDACTED]&a=1","msg":"fetch https://x?api_key=[REDACTED]"}
{"level":40,...,"component":"worker","password":"p","err":{},"msg":"child"} ← 见下方"顺带发现"
{"level":30,...,"secret":"[REDACTED]","msg":"via pino.transport (worker_threads)"}
[18:04:42] INFO (987284): via pino-pretty transport
```
**顺带发现(和运行时无关,Node 下复现结果相同):** 现有 `apps/server/src/logger.ts` 的脱敏有两个漏洞:① `logger.child({ password })` 的 bindings 不经过 `formatters.log`,需要再加一个 `formatters.bindings`;② `redact()` 把 `Error` 当普通对象处理,结果 `err` 变成 `{}`,应该先按 `err.message/stack` 序列化,再做脱敏。
LogTape(npm 版 `@logtape/logtape`)写一个 redacting sink 包装约 5 行,输出 JSON Lines,也可用。**结论:保留 pino。**
### e. 测试
```text
$ deno run -A npm:[email protected] run # spike 自带用例
FAIL: Cannot find package '@spike/x' ← deno.json 成员不在 node_modules 里,Vite 解析不到
# vitest.config.ts 加上 resolve.alias 之后,连跑 3 次都是 5/5 通过(约 0.7s)
$ cd apps/server && deno run -A --no-lock npm:vitest run # 真实仓库,只读
Test Files 4 passed (4) Tests 15 passed (15)
```
真实各包在 Deno 和 Node 下的对照(`logs/vitest-real-packages.txt`):
| 包 | Deno | Node | 说明 |
|---|---|---|---|
| contracts / db / domain / testkit | 361 / 11 / 122 / 1 全过 | 同 | |
| adapters | 108 过 1 败 | 108 过 1 败 | 两边一样,是在改代码导致的,和运行时无关 |
| tasks | 在共享的开发 PG 上不稳 | 在共享的开发 PG 上也不稳 | 换成干净的 PG(55435)后 Deno 下 **15/15**,包括子进程 kill -9 的恢复用例 |
| agent | 26 过 1 败 | 27 全过 | 子进程用 `process.execPath` 启动,在 Deno 下这个路径就是 deno,原生加载 `contracts/src/index.ts` 时碰到 `export * from "./agent"` 这种不带扩展名的 import |
`deno test` 的写法(`logs/deno-test.txt`):只需把
```ts
import { afterAll, beforeAll, describe, expect, it } from "vitest";
```
换成
```ts
import { afterAll, beforeAll, describe, it } from "@std/testing/bdd";
import { expect } from "@std/expect";
```
`ok | 1 passed (6 steps)`。资源泄漏检查(sanitizer)也没有报错。要注意的差异:没有 `vi.fn/vi.mock`(改用 `@std/testing/mock` 的 `spy/stub`);没有 `describe.skipIf`(改成 `it({ ignore: cond })`);`toMatchObject`、`toEqual` 等常用 matcher 在 `@std/expect` 里都有。
### f. 前端
```text
$ deno run -A npm:react-router build
error: ... react-router/package.json did not have a bin property ← CLI 在 @react-router/dev 包里
$ deno run -A npm:@react-router/[email protected]/react-router build → ✓ built in 690ms,SPA Mode: Generated build/client/index.html
$ deno task build (执行 package.json 的 "react-router build";PATH 最前面放了会 exit 99 的假 node,构建仍然成功)
$ deno run -A npm:vite@7 dev --port 5181 --strictPort
GET / -> 200 228202B <title>UHD 媒体自动化控制台</title>;GET /app/root.tsx -> 200 text/javascript
$ deno run -A scripts/gen-design-tokens.mjs [--check] → 生成 / "登记表是最新的",exit 0;check-design-tokens.sh 通过
```
`deno task` 运行 `node_modules/.bin/*` 时用的是 Deno,不会去调系统的 node。
### g. TypeScript 7.0.2
- 包结构:`[email protected]` 的 `bin` 只有 `{ tsc }`,`lib/tsc.js` 只是个壳,真正执行的是平台包 `@typescript/typescript-linux-x64/lib/tsc`(Go 原生二进制)。Deno 下没有 `process.execve`,会走 `execFileSync` 分支,exit code 能正确透传。
- 命令:`deno run -A npm:[email protected]/tsc --noEmit -p tsconfig.tsc.json`。
- 结果对照(`logs/typecheck-compare.txt`、`logs/tsc7-real-repo.txt`、`logs/tsc7-web.txt`):
| | tsc 7.0.2 | deno check(TS 6.0.3) |
|---|---|---|
| spike 代码 | 0 错误,1.5s(约 12s CPU,多核并行) | 0 错误,2.6s |
| 同一批严格错误(noUncheckedIndexedAccess 等) | 报出,结果一致 | 报出,结果一致 |
| `jsr:` 包(如 `@std/expect`) | **解析不了**(TS2307) | 能解析 |
| `Deno.*` 全局 | 需要 `deno types > types/deno.d.ts` 加进 include | 内置 |
| 真实仓库 7 个包 + server(现有 tsconfig) | **全部 0 错误** | 不带扩展名的 import 未解决前:contracts 16、domain 7、db 3、tasks 9、agent 11、adapters 6、server 8 个解析错误;加 `--sloppy-imports` 后全部 0 错误 |
| web(React Router typegen 之后) | 删掉 `baseUrl` 后 0 错误(TS5102: Option 'baseUrl' has been removed) | 不适用 |
- **坑:** 只要 workspace 根目录有 `tsconfig.json`,`deno check` 就会读它的 `compilerOptions.lib`。如果里面写的是 `["ES2023","DOM"]`,Deno 会报找不到 `Deno` 名字,也不认识 `import.meta.main`。所以给 TS7 用的配置要换个文件名(`tsconfig.tsc.json`),或者写到各包目录里,不要放在根目录叫 `tsconfig.json`。
- 不带扩展名的 import 可以自动修:`deno lint --sloppy-imports --fix --rules-include=no-sloppy-imports <dir>`,验证过能改成 `./a.ts` 和 `./dir/index.ts`(`logs/sloppy-fix.txt`)。仓库 src+test 里大约有 400 行这样的 import。
### h. 容器与 Node 专有 API
```text
deno compile -A worker.ts → 440 MB(把整个 node_modules 443M 都嵌进去了,包括前端依赖和 TS 原生二进制)
deno compile --bundle → 141 MB
deno compile --node-modules-dir=none --exclude-unused-npm → 107 MB 三种都能正常消费 OW 任务并优雅停止
$ docker manifest inspect denoland/deno:2.9.7 → OCI index,linux/amd64 + linux/arm64(Debian 13 trixie,默认 root 用户)
$ docker build(spikes/deno/Dockerfile:deno install → web build → deno compile → debian:trixie-slim)
image size 260MB;health 200;/ 200 text/html;/settings(SPA 回退)200;assets js 200;SSE 3 个事件;docker stop(SIGTERM)0.31s
```
第一版 Dockerfile 用的是 `deno run --cached-only` 加 `USER deno`,结果失败:`File exists (os error 17), symlink '../../packages/contracts' -> '/app/node_modules/@umt/contracts'`。原因是 `nodeModulesDir: auto` 在运行时还会尝试重建 node_modules 链接,而且镜像有 813 MB。**生产镜像应改用 compile 出的二进制;如果坚持用 `deno run`,就加 `--node-modules-dir=manual`,并在构建阶段用 root 装好依赖。**
Node API 探测(`src/node-compat.ts` → `logs/node-compat.txt`):`node:util parseArgs`、`fs.statfs`、`os.*`、`process.memoryUsage`、`AsyncLocalStorage`、`node:crypto`、`worker_threads`(`new Worker(url, {type:"module"})`)、`child_process.spawn` 加 SIGTERM、`process.on("SIGTERM")` 全部正常;`process.execPath` 是 `/usr/local/bin/deno`。
## 3. 清理
`umt-deno-pg`、`umt-deno-app` 两个容器和 `umt-deno-spike` 镜像已删除;compile 产物、`web/build`、会话目录已删除;5181、5199、58080、55435 端口都没有监听进程。`spikes/deno/node_modules`(443 MB)被 .gitignore 忽略,需要时执行 `deno install` 重建。
+6
View File
@@ -0,0 +1,6 @@
**/node_modules
dist
logs
web/build
web/.react-router
apps/server/src/pi/.sessions
+2
View File
@@ -0,0 +1,2 @@
types/deno.d.ts
apps/server/src/pi/.sessions/
+20
View File
@@ -0,0 +1,20 @@
# Single container: one `deno compile` binary runs API (+ worker role) and serves the prebuilt SPA.
FROM denoland/deno:2.9.7 AS build
WORKDIR /app
COPY . .
RUN deno install --frozen
RUN cd web && deno task build
# --node-modules-dir=none + --exclude-unused-npm: embed only npm packages reachable from the server graph
RUN deno compile --frozen --node-modules-dir=none --exclude-unused-npm \
--allow-net --allow-env --allow-sys --allow-read=/app \
--output /out/umt-server apps/server/src/http.ts
FROM debian:trixie-slim
RUN useradd -r -u 1000 umt
WORKDIR /app
COPY --from=build /out/umt-server /app/umt-server
COPY --from=build /app/web/build/client /app/public
ENV WEB_ROOT=/app/public HOST=0.0.0.0 PORT=8080
USER umt
EXPOSE 8080
ENTRYPOINT ["/app/umt-server"]
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@spike/server",
"version": "0.0.0",
"exports": { ".": "./src/main.ts" },
"tasks": {
"dev": "deno run -A --watch src/main.ts",
"test": "deno test -A test/node_apis_test.ts"
}
}
+50
View File
@@ -0,0 +1,50 @@
// Kysely 0.29 on Deno: DIALECT=pg (node-postgres Pool) or DIALECT=postgresjs (kysely-postgres-js).
import { Kysely, PostgresDialect, sql } from "kysely";
import { FileMigrationProvider, Migrator } from "kysely/migration";
import { PostgresJSDialect } from "kysely-postgres-js";
import pg from "pg";
import postgres from "postgres";
import { promises as fs } from "node:fs";
import * as path from "node:path";
const url = Deno.env.get("PG_URL") ?? "postgresql://postgres:[email protected]:55435/postgres";
const dialectName = Deno.env.get("DIALECT") ?? "pg";
const schema = Deno.env.get("SCHEMA") ?? `kysely_${dialectName}`;
export function createDb(): Kysely<any> {
const dialect = dialectName === "postgresjs"
? new PostgresJSDialect({ postgres: postgres(url, { max: 4, onnotice: () => {} }) })
: new PostgresDialect({ pool: new pg.Pool({ connectionString: url, max: 4 }) });
return new Kysely<any>({ dialect });
}
if (import.meta.main) {
const root = createDb();
await sql`DROP SCHEMA IF EXISTS ${sql.id(schema)} CASCADE`.execute(root);
await sql`CREATE SCHEMA ${sql.id(schema)}`.execute(root);
const db = root.withSchema(schema);
const migrator = new Migrator({
db: root,
migrationTableSchema: schema,
provider: new FileMigrationProvider({ fs, path, migrationFolder: Deno.env.get("MIGRATIONS_DIR") ?? new URL("./migrations", import.meta.url).pathname }),
});
// Migrations use unqualified names: run them with search_path pointing at the dialect's schema.
await sql`SET search_path TO ${sql.id(schema)}`.execute(root);
const r = await migrator.migrateToLatest();
console.log(dialectName, "migrate:", JSON.stringify(r.results?.map((x) => `${x.migrationName}:${x.status}`)), "error:", r.error ?? null);
if (Deno.env.get("MIGRATIONS_DIR")) { await root.destroy(); Deno.exit(r.error ? 1 : 0); }
await db.insertInto("items").values([{ title: "Dune", meta: JSON.stringify({ q: "1080p" }) }, { title: "Arrival", meta: null }]).execute();
const rows = await db.selectFrom("items").select(["id", "title", "meta", "created_at"]).orderBy("id").execute();
console.log(dialectName, "rows:", rows.map((r) => `${typeof r.id}:${r.id}/${r.title}/${JSON.stringify(r.meta)}/${r.created_at instanceof Date}`).join(" "));
// transaction + rollback
await db.transaction().execute(async (tx) => { await tx.insertInto("items").values({ title: "tx-ok" }).execute(); }).catch(() => {});
await db.transaction().execute(async (tx) => { await tx.insertInto("items").values({ title: "tx-rollback" }).execute(); throw new Error("rollback"); }).catch((e) => console.log(dialectName, "tx error ->", e.message));
const titles = (await db.selectFrom("items").select("title").execute()).map((r) => r.title);
console.log(dialectName, "titles after tx:", titles.join(","));
// concurrency: 50 parallel queries on pool max=4
const t0 = performance.now();
await Promise.all(Array.from({ length: 50 }, (_, i) => sql`SELECT pg_sleep(0.01), ${i}::int AS i`.execute(root)));
console.log(dialectName, `50 parallel queries on pool=4: ${(performance.now() - t0).toFixed(0)}ms`);
await root.destroy();
console.log(dialectName, "destroyed cleanly");
}
+52
View File
@@ -0,0 +1,52 @@
// Hono 4 on Deno.serve with an SSE stream + graceful shutdown.
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { serveStatic } from "hono/deno";
import { redact } from "@spike/x";
export function createApp() {
const app = new Hono();
app.get("/api/v1/health", (c) => c.json({ ok: true, runtime: `deno ${Deno.version.deno}`, ts: Deno.version.typescript }));
app.get("/api/v1/redact", (c) => c.json(redact({ url: c.req.url, apiKey: "x" }) as Record<string, unknown>));
app.get("/api/v1/events", (c) =>
streamSSE(c, async (stream) => {
const after = Number(c.req.query("after") ?? 0);
let aborted = false;
stream.onAbort(() => { aborted = true; });
for (let i = after + 1; i <= after + 3 && !aborted; i++) {
await stream.writeSSE({ id: String(i), event: "tick", data: JSON.stringify({ i }) });
await stream.sleep(100);
}
}));
// Endless stream: used to check how Deno.serve shutdown treats open SSE connections.
app.get("/api/v1/forever", (c) =>
streamSSE(c, async (stream) => {
let i = 0;
while (!stream.aborted && !shutdownSignal.aborted) {
await stream.writeSSE({ id: String(++i), data: "ping" });
await stream.sleep(200);
}
}));
// Single-container mode: serve the built SPA (react-router ssr:false) from WEB_ROOT with index.html fallback.
const webRoot = Deno.env.get("WEB_ROOT");
if (webRoot) {
app.use("/*", serveStatic({ root: webRoot }));
app.get("*", serveStatic({ root: webRoot, path: "index.html" }));
}
return app;
}
/** App-level shutdown signal: SSE loops must observe it, otherwise graceful shutdown waits forever. */
export const shutdownCtl = new AbortController();
const shutdownSignal = shutdownCtl.signal;
if (import.meta.main) {
const port = Number(Deno.env.get("PORT") ?? 5199);
const ac = new AbortController();
const server = Deno.serve({ port, hostname: Deno.env.get("HOST") ?? "127.0.0.1", signal: ac.signal, onListen: ({ port }) => console.log(`listening ${port}`) }, createApp().fetch);
const stop = () => { console.log("shutdown"); if (Deno.env.get("CLOSE_SSE") !== "0") shutdownCtl.abort(); ac.abort(); };
Deno.addSignalListener("SIGTERM", stop);
Deno.addSignalListener("SIGINT", stop);
await server.finished;
console.log("server finished");
}
@@ -0,0 +1,12 @@
// LogTape alternative: JSON lines + redaction sink wrapper.
import { configure, getJsonLinesFormatter, getLogger, getStreamSink, type LogRecord, type Sink } from "@logtape/logtape";
import { redact, redactString } from "@spike/x";
const base = getStreamSink(Deno.stdout.writable, { formatter: getJsonLinesFormatter() });
const redacting = (inner: Sink): Sink => (r: LogRecord) =>
inner({ ...r, message: r.message.map((m: unknown) => (typeof m === "string" ? redactString(m) : redact(m))), rawMessage: typeof r.rawMessage === "string" ? redactString(r.rawMessage) : r.rawMessage, properties: redact(r.properties) as Record<string, unknown> });
await configure({ sinks: { out: redacting(base) }, loggers: [{ category: ["umt"], lowestLevel: "info", sinks: ["out"] }, { category: ["logtape", "meta"], lowestLevel: "warning", sinks: ["out"] }] });
const log = getLogger(["umt", "worker"]);
log.info("fetch {url}", { url: "https://x/rss?passkey=SECRET", token: "abc", n: 3 });
log.with({ runId: "r1", apiKey: "k" }).warn("child context");
await new Promise((r) => setTimeout(r, 100));
+32
View File
@@ -0,0 +1,32 @@
// pino 10 on Deno: sync stdout, custom destination, formatters/hooks redaction, child loggers, pino.transport (worker_threads).
import pino from "pino";
import { redact, redactString } from "@spike/x";
import { Writable } from "node:stream";
const opts: pino.LoggerOptions = {
level: "info",
formatters: { log: (obj) => redact(obj) as Record<string, unknown> },
hooks: {
logMethod(args, method) {
return method.apply(this, args.map((a) => (typeof a === "string" ? redactString(a) : a)) as typeof args);
},
},
};
const lines: string[] = [];
const dest = new Writable({ write(chunk, _enc, cb) { lines.push(String(chunk)); cb(); } });
const log = pino(opts, dest);
log.info({ token: "abc", url: "https://x/rss?passkey=SECRET&a=1", n: 1 }, "fetch https://x?api_key=K");
log.child({ component: "worker", password: "p" }).warn({ err: new Error("boom token=zzz") }, "child");
console.log("custom-dest:", lines.join("").trim());
pino(opts).info({ cookie: "c=1" }, "stdout default destination");
const t = pino.transport({ target: "pino/file", options: { destination: 1 } });
const lt = pino(opts, t);
lt.info({ secret: "s" }, "via pino.transport (worker_threads)");
await new Promise((r) => setTimeout(r, 300));
t.end();
if (Deno.env.get("PRETTY")) {
const pp = pino({ ...opts, transport: { target: "pino-pretty", options: { translateTime: "SYS:HH:MM:ss" } } });
pp.info({ token: "x" }, "via pino-pretty transport");
await new Promise((r) => setTimeout(r, 500));
}
@@ -0,0 +1,10 @@
import { type Kysely, sql } from "kysely";
export async function up(db: Kysely<any>) {
await db.schema.createTable("items").ifNotExists()
.addColumn("id", "bigserial", (c) => c.primaryKey())
.addColumn("title", "text", (c) => c.notNull())
.addColumn("meta", "jsonb")
.addColumn("created_at", "timestamptz", (c) => c.notNull().defaultTo(sql`now()`))
.execute();
}
export async function down(db: Kysely<any>) { await db.schema.dropTable("items").execute(); }
@@ -0,0 +1,5 @@
import type { Kysely } from "kysely";
export async function up(db: Kysely<any>) {
await db.schema.createIndex("items_title_idx").ifNotExists().on("items").column("title").execute();
}
export async function down(db: Kysely<any>) { await db.schema.dropIndex("items_title_idx").execute(); }
@@ -0,0 +1,32 @@
// Node-only API probe under Deno (things the current server/tasks code uses or is likely to use).
import process from "node:process";
import { Worker, isMainThread, parentPort } from "node:worker_threads";
import { statfs } from "node:fs/promises";
import { spawn } from "node:child_process";
import { parseArgs } from "node:util";
import os from "node:os";
import { AsyncLocalStorage } from "node:async_hooks";
import { createHash, randomUUID } from "node:crypto";
if (!isMainThread) { parentPort!.postMessage({ echo: "from worker", pid: process.pid }); } else {
const r: Record<string, unknown> = {};
r.parseArgs = parseArgs({ args: ["--role", "api"], options: { role: { type: "string" } } }).values;
const s = await statfs("/");
r.statfs = { bsize: s.bsize, bavailGB: Math.round((s.bavail * s.bsize) / 1e9) };
r.os = { cpus: os.cpus().length, freememMB: Math.round(os.freemem() / 1e6), loadavg: os.loadavg().length };
r.memoryUsage = Object.keys(process.memoryUsage()).join(",");
const als = new AsyncLocalStorage<string>();
r.als = await als.run("req-1", async () => { await new Promise((x) => setTimeout(x, 5)); return als.getStore(); });
r.crypto = createHash("sha256").update("x").digest("hex").slice(0, 8) + " " + randomUUID().length;
r.worker = await new Promise((res) => { const w = new Worker(new URL(import.meta.url), { type: "module" } as any); w.once("message", (m) => { res(m); w.terminate(); }); });
const child = spawn("sh", ["-c", "trap 'echo got-TERM; exit 0' TERM; echo ready; sleep 5 & wait"]);
let out = ""; child.stdout.on("data", (d) => { out += d; });
await new Promise((x) => setTimeout(x, 200));
child.kill("SIGTERM");
r.childKill = await new Promise((res) => child.on("exit", (code, sig) => res({ code, sig, out: out.trim().replace(/\n/g, "|") })));
r.execPath = process.execPath;
console.log(JSON.stringify(r, null, 1));
process.on("SIGTERM", () => { console.log("process.on(SIGTERM) handler fired"); process.exit(0); });
setTimeout(() => process.kill(process.pid, "SIGTERM"), 100);
setTimeout(() => { console.log("handler did NOT fire"); process.exit(1); }, 2000);
}
+65
View File
@@ -0,0 +1,65 @@
import process from "node:process";
import { makeClient, initBiz, db } from "./common.ts";
import { sql } from "kysely";
await initBiz();
const { ow, backend, acquire, flaky, slow, burst } = await makeClient();
const [cmd, ...args] = process.argv.slice(2) as [string, ...string[]];
const out = (x: unknown) => console.log(JSON.stringify(x, null, 0));
switch (cmd) {
case "start-acquire": {
const [downloadId, key, copyMs, retention] = args;
const h = await acquire.run({ downloadId, copyMs: Number(copyMs ?? 0), retention: retention ?? "3s" },
key ? { idempotencyKey: key } : undefined);
out({ runId: h.workflowRun.id, status: h.workflowRun.status, version: h.workflowRun.version });
break;
}
case "start-flaky": {
const h = await flaky.run({ failTimes: Number(args[0]), maxAttempts: Number(args[1]) });
out({ runId: h.workflowRun.id });
break;
}
case "start-slow": { const h = await slow.run({}); out({ runId: h.workflowRun.id }); break; }
case "start-burst": {
const ids = [];
for (let i = 0; i < Number(args[0]); i++) ids.push((await burst.run({ i })).workflowRun.id);
out({ count: ids.length });
break;
}
case "signal": {
const r = await ow.sendSignal({ signal: `download-done:${args[0]}`, data: { ok: true }, idempotencyKey: args[1] });
out(r);
break;
}
case "cancel": { await ow.cancelWorkflowRun(args[0]!); out({ canceled: args[0] }); break; }
case "get": {
const r = await backend.getWorkflowRun({ workflowRunId: args[0]! });
out({ status: r?.status, version: r?.version, attempts: r?.attempts, error: r?.error, output: r?.output });
break;
}
case "steps": {
const r = await backend.listStepAttempts({ workflowRunId: args[0], limit: 100 } as any);
for (const s of [...r.data].sort((a,b)=>+a.createdAt-+b.createdAt)) console.log(s.stepName.padEnd(18), s.kind.padEnd(12), s.status, s.error ? JSON.stringify((s.error as any).message) : "");
break;
}
case "ledger": {
const rows = await db.selectFrom("biz.side_effect_ledger").selectAll()
.$if(!!args[0], (q) => q.where("run_id", "=", args[0])).orderBy("id").execute();
for (const r of rows) console.log(r.run_id.slice(0, 8), r.step.padEnd(16), r.worker.padEnd(6), r.note);
break;
}
case "burst-dups": {
const rows = await sql<any>`SELECT count(*) total, count(DISTINCT run_id) runs,
count(*) FILTER (WHERE worker='A') a, count(*) FILTER (WHERE worker='B') b
FROM biz.side_effect_ledger WHERE step='work'`.execute(db);
out(rows.rows[0]);
break;
}
case "tables": {
const r = await sql<any>`SELECT table_schema, table_name FROM information_schema.tables
WHERE table_schema IN ('openworkflow','biz') ORDER BY 1,2`.execute(db);
for (const t of r.rows) console.log(`${t.table_schema}.${t.table_name}`);
break;
}
}
await backend.stop();
await db.destroy();
+111
View File
@@ -0,0 +1,111 @@
// Shared: DB URLs, OpenWorkflow client, Kysely business DB (schema "biz"), workflow definitions.
import { OpenWorkflow } from "openworkflow";
import { BackendPostgres } from "openworkflow/postgres";
import { Kysely, PostgresDialect, sql } from "kysely";
import pg from "pg";
import process from "node:process";
export const PG_URL = process.env.PG_URL ?? "postgresql://postgres:[email protected]:55435/postgres";
export const WF_VERSION = process.env.WF_VERSION ?? "v1";
export const TAG = process.env.TAG ?? `pid${process.pid}`;
export const db = new Kysely<any>({
dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 4 }) }),
});
export async function initBiz() {
await sql`CREATE SCHEMA IF NOT EXISTS biz`.execute(db);
await sql`CREATE TABLE IF NOT EXISTS biz.side_effect_ledger (
id bigserial PRIMARY KEY, run_id text, step text, worker text, pid int, note text,
at timestamptz DEFAULT now())`.execute(db);
}
export async function ledger(runId: string, step: string, note = "") {
await db.insertInto("biz.side_effect_ledger")
.values({ run_id: runId, step, worker: TAG, pid: process.pid, note }).execute();
console.log(`[${TAG}] run=${runId.slice(0, 8)} step=${step} ${note}`);
}
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function makeClient() {
// connect() runs migrations by default (runMigrations: true), schema default "openworkflow".
const backend = await BackendPostgres.connect(PG_URL, { schema: "openworkflow" });
const ow = new OpenWorkflow({ backend });
// Main vertical slice: submit download -> wait signal -> copy -> register -> retention sleep -> cleanup
const acquire = ow.defineWorkflow(
{ name: "acquire", version: WF_VERSION },
async ({ input, step, run, version }) => {
const { downloadId, copyMs = 0, retention = "3s" } = input as any;
const sub = await step.run({ name: "submit-download" }, async () => {
await ledger(run.id, "submit-download", `version=${version}`);
return { torrentHash: `hash-${downloadId}` };
});
const done = await step.waitForSignal<{ ok: boolean }>({
name: "wait-download", signal: `download-done:${downloadId}`, timeout: "10m",
});
if (!done) throw new Error("download wait timed out");
const copied = await step.run({ name: "copy-file" }, async () => {
await ledger(run.id, "copy-file", "start");
await wait(copyMs);
await ledger(run.id, "copy-file", "end");
return { dest: `/lib/${downloadId}.mkv` };
});
if (process.env.EXTRA_STEP === "1") {
// EXTRA_STEP=1 simulates a code change between copy and register (tests code upgrade semantics)
await step.run({ name: "v2-extra-verify" }, async () => { await ledger(run.id, "v2-extra-verify"); return 1; });
}
await step.run({ name: "register" }, async () => { await ledger(run.id, "register", `version=${version}`); return 1; });
await step.sleep("retention", retention);
await step.run({ name: "cleanup" }, async () => { await ledger(run.id, "cleanup"); return 1; });
return { sub, copied, signal: done.data };
},
);
// Retry test: step fails `failTimes` times then succeeds; step maximumAttempts = maxAttempts
const flaky = ow.defineWorkflow(
{ name: "flaky", version: WF_VERSION },
async ({ input, step, run }) => {
const { failTimes, maxAttempts } = input as any;
await step.run({ name: "before" }, async () => { await ledger(run.id, "before"); return 1; });
return await step.run(
{ name: "flaky-step", retryPolicy: { maximumAttempts: maxAttempts, initialInterval: "200ms", maximumInterval: "1s" } },
async () => {
const n = await db.selectFrom("biz.side_effect_ledger").select(db.fn.countAll().as("c"))
.where("run_id", "=", run.id).where("step", "=", "flaky-step").executeTakeFirst();
const attempt = Number(n?.c ?? 0) + 1;
await ledger(run.id, "flaky-step", `attempt=${attempt}`);
if (attempt <= failTimes) throw new Error(`boom attempt ${attempt}`);
return { attempt };
},
);
},
);
// Cancel test: long step, then a follow-up step
const slow = ow.defineWorkflow(
{ name: "slow", version: WF_VERSION },
async ({ step, run }) => {
await step.run({ name: "long" }, async () => {
await ledger(run.id, "long", "start");
await wait(8000);
await ledger(run.id, "long", "end (still ran after cancel?)");
return 1;
});
await step.run({ name: "after-long" }, async () => { await ledger(run.id, "after-long"); return 1; });
return "done";
},
);
// Concurrency test: many short runs
const burst = ow.defineWorkflow(
{ name: "burst", version: WF_VERSION },
async ({ step, run }) => {
await step.run({ name: "work" }, async () => { await wait(300); await ledger(run.id, "work"); return 1; });
return 1;
},
);
return { ow, backend, acquire, flaky, slow, burst };
}
+14
View File
@@ -0,0 +1,14 @@
// Deno port of spikes/openworkflow/worker.ts: node:fs/process.on -> Deno APIs
import { initBiz, makeClient, TAG, WF_VERSION } from "./common.ts";
await initBiz();
const { ow } = await makeClient();
const worker = ow.newWorker({ concurrency: Number(Deno.env.get("CONCURRENCY") ?? 4) });
await worker.start();
const pidfile = Deno.env.get("PIDFILE");
if (pidfile) Deno.writeTextFileSync(pidfile, String(Deno.pid));
console.log(`[${TAG}] worker started pid=${Deno.pid} version=${WF_VERSION} runtime=deno ${Deno.version.deno}`);
Deno.addSignalListener("SIGTERM", async () => {
await worker.stop();
console.log(`[${TAG}] worker stopped gracefully`);
Deno.exit(0);
});
@@ -0,0 +1,61 @@
import process from "node:process";
// Modes: abort | crash | resume. Uses a file-backed JSONL session so a second process can recover.
import { createModels, fauxProvider } from "@earendil-works/pi-ai";
import { AgentHarness, BACKGROUND_CONTEXT, JsonlSessionRepo } from "@earendil-works/pi-agent-core";
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/harness/env/nodejs";
import { downloadSubmit, inventoryQuery, script, submitted } from "./common.ts";
const mode = process.argv[2];
const ctx = BACKGROUND_CONTEXT;
const faux = fauxProvider({ provider: "umt-fake" });
const models = createModels();
models.setProvider(faux.provider);
faux.setResponses(mode === "resume" ? [script[2]!] : script);
const root = new URL("./.sessions", import.meta.url).pathname;
const fs = new NodeExecutionEnv({ cwd: root }); // used only as FileSystem for session storage; no shell tool is exposed
const repo = new JsonlSessionRepo({ fileSystem: fs, sessionsRoot: root });
let session;
if (mode === "resume") {
const list = await repo.list({ cwd: root }, ctx);
const meta = list.find((m) => m.id === "crash-demo");
console.log("found session", meta?.id, meta?.path);
session = await repo.open(meta!, ctx);
} else {
session = await repo.create({ id: mode === "crash" ? "crash-demo" : `abort-${Date.now()}`, cwd: root }, ctx);
}
const { harness, open } = await AgentHarness.create({
session, models, model: faux.getModel(), systemPrompt: "媒体库助手",
tools: [inventoryQuery, downloadSubmit], toolContext: { actorId: "user:1", grants: ["download.submit"] },
toolExecution: "sequential",
}, ctx);
console.log("open operations at attach:", JSON.stringify(open));
let lane = await harness.lane("main", ctx);
for (const t of ["tool_start", "tool_end", "run_end", "operation_abort", "run_resume"] as const)
harness.events.on(t as any, async (e: any) => {
console.log(`[${e.type}]${e.recovery ? " (recovery)" : ""} ${e.toolName ?? ""} ${e.status ?? ""} ${e.result ? JSON.stringify(e.result.content[0]?.text).slice(0, 220) : ""}`);
if (e.type === "tool_start" && e.toolName === "download__submit") {
if (mode === "abort") setTimeout(async () => { console.log(">> lane.abort()"); console.log("abort result:", JSON.stringify(await lane.abort(ctx))); }, 300);
if (mode === "crash") setTimeout(() => { console.log(">> SIGKILL self during download.submit"); process.kill(process.pid, "SIGKILL"); }, 300);
}
});
if (mode === "resume") {
const r = await lane.resume(ctx);
console.log("RESUME RESULT", JSON.stringify(r));
} else {
const r = await lane.prompt("把《Dune》1080p 收进来", undefined, ctx);
console.log("PROMPT RESULT", JSON.stringify(r));
}
console.log("SUBMITTED in this process", JSON.stringify(submitted), "fake calls:", faux.state.callCount);
const entries = (await lane.findEntries(undefined, ctx)).reverse();
for (const e of entries as any[]) {
if (e.type !== "message") { console.log(" entry", e.type); continue; }
const m = e.message;
const text = m.role === "assistant" ? m.content.map((c: any) => c.type === "toolCall" ? `toolCall(${c.name})` : c.type === "text" ? c.text : `<${c.type}>`).join(" | ")
: Array.isArray(m.content) ? m.content.map((c: any) => c.text).join("") : m.content;
console.log(` ${m.role.padEnd(10)} ${m.toolName ?? ""} ${m.isError ? "[isError]" : ""} ${String(text).slice(0, 200)}`);
}
await harness.close(ctx);
+84
View File
@@ -0,0 +1,84 @@
import process from "node:process";
// Shared pieces for the Pi spike: whitelisted business tools + scripted fake model.
import { Type } from "typebox";
import {
fauxAssistantMessage, fauxText, fauxThinking, fauxToolCall,
type FauxResponseFactory,
} from "@earendil-works/pi-ai";
import type { AgentHarnessTool } from "@earendil-works/pi-agent-core";
// Capability id -> provider-safe tool name. Anthropic/OpenAI tool names must match ^[a-zA-Z0-9_-]{1,64}$,
// so "inventory.query" cannot be sent verbatim; the port maps ids <-> names.
export const toolName = (capabilityId: string) => capabilityId.replace(/\./g, "__");
export const submitted: Array<{ key: string; title: string }> = [];
type Ctx = { actorId: string; grants: string[] };
export const inventoryQuery: AgentHarnessTool<Ctx> = {
name: toolName("inventory.query"),
label: "inventory.query",
description: "Query local library inventory for a title. Returns whether it exists and which versions.",
parameters: Type.Object({ title: Type.String(), year: Type.Optional(Type.Number()) }),
replay: "safe", // read-only: harness may re-execute after a crash
async execute(_id, params: any, onUpdate, toolCtx, invocation, context) {
onUpdate({ content: [{ type: "text", text: "searching…" }], details: {} });
const found = params.title === "Arrival";
return {
content: [{ type: "text", text: JSON.stringify({ title: params.title, inLibrary: found, versions: found ? ["2160p"] : [] }) }],
details: { actor: toolCtx.actorId, invocationId: invocation.invocationId, aborted: context.abortSignal?.aborted ?? false },
};
},
};
export const downloadSubmit: AgentHarnessTool<Ctx> = {
name: toolName("download.submit"),
label: "download.submit",
description: "Submit a download plan. Server validates authorization and generates the idempotency key.",
parameters: Type.Object({ title: Type.String(), quality: Type.String() }),
replay: "never", // external side effect: after crash the harness synthesizes an 'interrupted' result instead of re-running
executionMode: "sequential",
async execute(_id, params: any, _onUpdate, toolCtx, invocation, context) {
if (!toolCtx.grants.includes("download.submit")) throw new Error("not authorized: download.submit");
// Server-side idempotency key derived from operation semantics, not from the model.
const key = `dl:${params.title}:${params.quality}`;
const delayMs = Number(process.env.SUBMIT_DELAY_MS ?? 0);
if (delayMs) {
await new Promise<void>((resolve, reject) => {
const t = setTimeout(resolve, delayMs);
context.abortSignal?.addEventListener("abort", () => { clearTimeout(t); reject(new Error("aborted by signal")); });
});
}
if (!submitted.find((s) => s.key === key)) submitted.push({ key, title: params.title });
await invocation.setMemo("submitted", key);
return { content: [{ type: "text", text: JSON.stringify({ accepted: true, idempotencyKey: key, status: "submitted" }) }], details: { key } };
},
};
const lastToolResult = (ctx: any, name: string) =>
[...ctx.messages].reverse().find((m: any) => m.role === "toolResult" && m.toolName === name);
// Scripted fake model: each step is a factory that looks at the transcript (tool results) and decides.
export const script: FauxResponseFactory[] = [
() => fauxAssistantMessage([
fauxThinking("先查本地库存"),
fauxText("我先查一下库里有没有《Dune》。"),
fauxToolCall(toolName("inventory.query"), { title: "Dune", year: 2021 }),
], { stopReason: "toolUse" }),
(ctx) => {
const inv = JSON.parse(lastToolResult(ctx, toolName("inventory.query")).content[0].text);
if (inv.inLibrary) return fauxAssistantMessage("库里已有,无需下载。");
return fauxAssistantMessage([
fauxText("库里没有,提交 1080p 下载;顺便试一下未授权的 bash。"),
fauxToolCall(toolName("download.submit"), { title: "Dune", quality: "1080p" }),
fauxToolCall("bash", { command: "rm -rf /" }),
], { stopReason: "toolUse" });
},
(ctx) => {
const dl = lastToolResult(ctx, toolName("download.submit"));
const bash = lastToolResult(ctx, "bash");
return fauxAssistantMessage(
`下载已提交(仅代表已提交,不代表已入库):${dl.content[0].text};bash 结果 isError=${bash?.isError}。`,
);
},
];
@@ -0,0 +1,51 @@
import process from "node:process";
// Fake model drives AgentHarness through 2 whitelisted tools; streams events.
import { createModels, fauxProvider } from "@earendil-works/pi-ai";
import { AgentHarness, BACKGROUND_CONTEXT, MemorySessionRepo } from "@earendil-works/pi-agent-core";
import { downloadSubmit, inventoryQuery, script, submitted } from "./common.ts";
const faux = fauxProvider({ provider: "umt-fake" });
const models = createModels();
models.setProvider(faux.provider);
faux.setResponses(script);
const repo = new MemorySessionRepo();
const session = await repo.create({ id: "spike-1" }, BACKGROUND_CONTEXT);
const { harness } = await AgentHarness.create({
session, models, model: faux.getModel(),
systemPrompt: "你是媒体库自动化助手,只能使用提供的工具。",
tools: [inventoryQuery, downloadSubmit], // explicit whitelist; no discovery
toolContext: { actorId: "user:1", grants: ["download.submit"] },
thinkingLevel: "off",
compaction: { enabled: true, reserveTokens: 4096, keepRecentTokens: 8000 },
retry: { enabled: true, maxRetries: 2, baseDelayMs: 100 },
toolExecution: "sequential",
}, BACKGROUND_CONTEXT);
// Server-side authorization gate in addition to the whitelist.
harness.hooks.on("before_tool", (ev) => {
console.log(` [hook before_tool] ${ev.toolName} args=${JSON.stringify(ev.args)}`);
return undefined;
});
const types = ["run_start", "turn_start", "message_update", "tool_start", "tool_update", "tool_end", "turn_end", "run_end", "usage", "fault", "handler_error"] as const;
for (const t of types) harness.events.on(t as any, (e: any) => {
if (e.type === "message_update") {
if (e.event.type === "text_delta") process.stdout.write(` [text_delta] ${JSON.stringify(e.event.delta)}\n`);
else if (e.event.type === "toolcall_end") console.log(` [toolcall_end] ${e.event.toolCall.name}`);
return;
}
if (e.type === "tool_end") return console.log(`[tool_end] ${e.toolName} isError=${e.isError} -> ${JSON.stringify(e.result.content[0]?.text)}`);
if (e.type === "tool_start") return console.log(`[tool_start] ${e.toolName} ${JSON.stringify(e.args)}`);
if (e.type === "usage") return console.log(`[usage] in=${e.row?.usage?.input ?? "?"} totals.input=${e.totals.input} output=${e.totals.output}`);
if (e.type === "run_end") return console.log(`[run_end] status=${e.status}${e.error ? " error=" + JSON.stringify(e.error) : ""}`);
console.log(`[${e.type}]${e.lane ? " lane=" + e.lane : ""}`);
});
const lane = await harness.lane("main", BACKGROUND_CONTEXT);
const result = await lane.prompt("把《Dune》1080p 收进来", undefined, BACKGROUND_CONTEXT);
console.log("RESULT", JSON.stringify(result));
console.log("SUBMITTED", JSON.stringify(submitted), "fake calls:", faux.state.callCount);
const entries = await lane.findEntries(undefined, BACKGROUND_CONTEXT);
console.log("TRANSCRIPT roles:", entries.map((e: any) => e.type === "message" ? e.message.role : e.type).join(","));
await harness.close(BACKGROUND_CONTEXT);
@@ -0,0 +1,54 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createServer, type Server } from "node:http";
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile, statfs } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApp } from "../src/http.ts";
import { redact } from "@spike/x";
let server: Server;
let base = "";
beforeAll(async () => {
server = createServer((req, res) => {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ path: req.url, method: req.method }));
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
const addr = server.address();
base = `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`;
});
afterAll(async () => { await new Promise((r) => server.close(r)); });
describe("node compat under test runner", () => {
it("node:http mock server + fetch", async () => {
const r = await fetch(`${base}/api/v2/torrents/info?x=1`);
expect(await r.json()).toEqual({ path: "/api/v2/torrents/info?x=1", method: "GET" });
});
it("node:child_process spawn + kill", async () => {
const child = spawn("sleep", ["30"]);
await new Promise((r) => setTimeout(r, 100));
const exited = new Promise<{ code: number | null; signal: string | null }>((r) => child.on("exit", (code, signal) => r({ code, signal })));
expect(child.kill("SIGKILL")).toBe(true);
expect(await exited).toEqual({ code: null, signal: "SIGKILL" });
});
it("fs temp dir + statfs", async () => {
const dir = await mkdtemp(join(tmpdir(), "umt-"));
await writeFile(join(dir, "a.txt"), "hi");
expect(await readFile(join(dir, "a.txt"), "utf8")).toBe("hi");
const s = await statfs(dir);
expect(s.bsize).toBeGreaterThan(0);
expect(typeof s.bavail).toBe("number");
await rm(dir, { recursive: true });
});
it("hono app.request SSE", async () => {
const res = await createApp().request("/api/v1/events?after=1");
expect(res.headers.get("content-type")).toContain("text/event-stream");
const text = await res.text();
expect(text).toContain("id: 2");
expect(text).toContain("event: tick");
});
it("workspace package import", () => {
expect(redact({ token: "x" })).toEqual({ token: "[REDACTED]" });
});
});
@@ -0,0 +1,55 @@
import { afterAll, beforeAll, describe, it } from "@std/testing/bdd";
import { expect } from "@std/expect";
import { createServer, type Server } from "node:http";
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile, statfs } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApp } from "../src/http.ts";
import { redact } from "@spike/x";
let server: Server;
let base = "";
beforeAll(async () => {
server = createServer((req, res) => {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ path: req.url, method: req.method }));
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
const addr = server.address();
base = `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`;
});
afterAll(async () => { await new Promise((r) => server.close(r)); });
describe("node compat under test runner", () => {
it("node:http mock server + fetch", async () => {
const r = await fetch(`${base}/api/v2/torrents/info?x=1`);
expect(await r.json()).toEqual({ path: "/api/v2/torrents/info?x=1", method: "GET" });
});
it("node:child_process spawn + kill", async () => {
const child = spawn("sleep", ["30"]);
await new Promise((r) => setTimeout(r, 100));
const exited = new Promise<{ code: number | null; signal: string | null }>((r) => child.on("exit", (code, signal) => r({ code, signal })));
expect(child.kill("SIGKILL")).toBe(true);
expect(await exited).toEqual({ code: null, signal: "SIGKILL" });
});
it("fs temp dir + statfs", async () => {
const dir = await mkdtemp(join(tmpdir(), "umt-"));
await writeFile(join(dir, "a.txt"), "hi");
expect(await readFile(join(dir, "a.txt"), "utf8")).toBe("hi");
const s = await statfs(dir);
expect(s.bsize).toBeGreaterThan(0);
expect(typeof s.bavail).toBe("number");
await rm(dir, { recursive: true });
});
it("hono app.request SSE", async () => {
const res = await createApp().request("/api/v1/events?after=1");
expect(res.headers.get("content-type")).toContain("text/event-stream");
const text = await res.text();
expect(text).toContain("id: 2");
expect(text).toContain("event: tick");
});
it("workspace package import", () => {
expect(redact({ token: "x" })).toEqual({ token: "[REDACTED]" });
});
});
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vitest/config";
// Vite's resolver does not read deno.json workspace/imports: map workspace members manually.
export default defineConfig({
resolve: { alias: { "@spike/x": new URL("../../packages/x/src/mod.ts", import.meta.url).pathname } },
test: { include: ["test/**/*.test.ts"] },
});
+55
View File
@@ -0,0 +1,55 @@
{
"workspace": [
"./packages/x",
"./packages/contracts",
"./apps/server",
"./web"
],
"nodeModulesDir": "auto",
"imports": {
"openworkflow": "npm:[email protected]",
"openworkflow/postgres": "npm:[email protected]/postgres",
"postgres": "npm:postgres@^3.4.9",
"kysely": "npm:kysely@^0.29.6",
"kysely/": "npm:/kysely@^0.29.6/",
"kysely-postgres-js": "npm:kysely-postgres-js@^5.0.1",
"pg": "npm:pg@^8.23.0",
"@types/pg": "npm:@types/pg@^8",
"@earendil-works/pi-agent-core": "npm:@earendil-works/[email protected]",
"@earendil-works/pi-agent-core/": "npm:/@earendil-works/[email protected]/",
"@earendil-works/pi-ai": "npm:@earendil-works/[email protected]",
"typebox": "npm:typebox@^1",
"hono": "npm:hono@^4.13.9",
"hono/": "npm:/hono@^4.13.9/",
"pino": "npm:pino@^10.3.1",
"pino-pretty": "npm:pino-pretty@^13.1.3",
"@logtape/logtape": "npm:@logtape/logtape@^2",
"@std/expect": "jsr:@std/expect@^1",
"@std/testing": "jsr:@std/testing@^1",
"vitest": "npm:[email protected]",
"typescript": "npm:[email protected]",
"@types/node": "npm:@types/node@^24"
},
"tasks": {
"typecheck": "deno check",
"tsgo": "deno task types && deno run -A npm:[email protected]/tsc --noEmit -p tsconfig.tsc.json",
"types": "deno types > types/deno.d.ts"
},
"lint": {
"rules": {
"exclude": [
"no-explicit-any"
]
}
},
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"lib": [
"deno.window",
"deno.unstable",
"dom"
]
}
}
+5200
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
[C] worker started pid=1036397 version=v1 runtime=deno 2.9.7
[C] run=a8a8a202 step=work
[C] run=d1692ed7 step=work
[C] run=617df139 step=work
[C] run=e22ffd1b step=work
[C] run=6692a31b step=work
[C] worker stopped gracefully
{"ok":true,"runtime":"deno 2.9.7","ts":"6.0.3"}== ow-worker-bundle
[ow-worker-bundle] worker started pid=1038194 version=v1 runtime=deno 2.9.7
[ow-worker-bundle] run=8c780ba3 step=work
[ow-worker-bundle] run=fc82d7c3 step=work
[ow-worker-bundle] worker stopped gracefully
== ow-worker-slim
[ow-worker-slim] worker started pid=1038731 version=v1 runtime=deno 2.9.7
[ow-worker-slim] run=4bfca25f step=work
[ow-worker-slim] run=781c6f72 step=work
[ow-worker-slim] worker stopped gracefully
@@ -0,0 +1,4 @@
[ow-worker-bundle] worker started pid=1038194 version=v1 runtime=deno 2.9.7
[ow-worker-bundle] run=8c780ba3 step=work
[ow-worker-bundle] run=fc82d7c3 step=work
[ow-worker-bundle] worker stopped gracefully
@@ -0,0 +1,4 @@
[ow-worker-slim] worker started pid=1038731 version=v1 runtime=deno 2.9.7
[ow-worker-slim] run=4bfca25f step=work
[ow-worker-slim] run=781c6f72 step=work
[ow-worker-slim] worker stopped gracefully
+7
View File
@@ -0,0 +1,7 @@
[C] worker started pid=1036397 version=v1 runtime=deno 2.9.7
[C] run=a8a8a202 step=work
[C] run=d1692ed7 step=work
[C] run=617df139 step=work
[C] run=e22ffd1b step=work
[C] run=6692a31b step=work
[C] worker stopped gracefully
@@ -0,0 +1,7 @@
packages/contracts/src/index.ts: 0 errors;
packages/domain/src/index.ts: 0 errors;
packages/db/src/index.ts: 0 errors;
packages/tasks/src/index.ts: 0 errors;
packages/agent/src/index.ts: 0 errors;
packages/adapters/src/index.ts: 0 errors;
apps/server/src/main.ts: 0 errors;
@@ -0,0 +1,7 @@
packages/contracts/src/index.ts: 16 unresolved-imports, 16 errors; first: TS2307 [ERROR]: Cannot find module 'file:///opt/uhd_media_tool/packages/contracts/src/agent'. Maybe add a '.ts' extension or run with --sloppy-imports
packages/domain/src/index.ts: 7 unresolved-imports, 7 errors; first: TS2307 [ERROR]: Cannot find module 'file:///opt/uhd_media_tool/packages/domain/src/acquisition'. Maybe specify path to 'index.ts' file in directory in
packages/db/src/index.ts: 3 unresolved-imports, 3 errors; first: TS2307 [ERROR]: Cannot find module 'file:///opt/uhd_media_tool/packages/db/src/client'. Maybe add a '.ts' extension or run with --sloppy-imports
packages/tasks/src/index.ts: 9 unresolved-imports, 9 errors; first: TS2307 [ERROR]: Cannot find module 'file:///opt/uhd_media_tool/packages/tasks/src/definitions'. Maybe add a '.ts' extension or run with --sloppy-impor
packages/agent/src/index.ts: 11 unresolved-imports, 11 errors; first: TS2307 [ERROR]: Cannot find module 'file:///opt/uhd_media_tool/packages/agent/src/capability/errors'. Maybe add a '.ts' extension or run with --sloppy
packages/adapters/src/index.ts: 6 unresolved-imports, 6 errors; first: TS2307 [ERROR]: Cannot find module 'file:///opt/uhd_media_tool/packages/adapters/src/emby'. Maybe specify path to 'index.ts' file in directory instead
apps/server/src/main.ts: 8 unresolved-imports, 8 errors; first: TS2307 [ERROR]: Cannot find module 'file:///opt/uhd_media_tool/apps/server/src/config'. Maybe add a '.ts' extension or run with --sloppy-imports
+14
View File
@@ -0,0 +1,14 @@
Check test/node_apis_test.ts
running 1 test from ./test/node_apis_test.ts
global ...
node compat under test runner ...
node:http mock server + fetch ... ok (15ms)
node:child_process spawn + kill ... ok (107ms)
fs temp dir + statfs ... ok (6ms)
hono app.request SSE ... ok (314ms)
workspace package import ... ok (0ms)
node compat under test runner ... ok (445ms)
global ... ok (453ms)
ok | 1 passed (6 steps) | 0 failed (460ms)
+12
View File
@@ -0,0 +1,12 @@
image size 260MB
health: {"ok":true,"runtime":"deno 2.9.7","ts":"6.0.3"}
/ -> 200 text/html; charset=utf-8
/settings (SPA fallback) -> 404 text/plain; charset=UTF-8
assets/manifest-f425e467.js -> 200 text/javascript; charset=utf-8
sse: 3 events
docker stop (SIGTERM) took .309456482s
logs:
listening 8080
shutdown
server finished
/settings (SPA fallback, container) -> 200 text/html; charset=utf-8
+3
View File
@@ -0,0 +1,3 @@
listening 5199
shutdown
server finished
+3
View File
@@ -0,0 +1,3 @@
listening 5199
shutdown
server finished
+3
View File
@@ -0,0 +1,3 @@
listening 5199
shutdown
server finished
+2
View File
@@ -0,0 +1,2 @@
CLOSE_SSE=0 server exit after .210191521s alive=no
CLOSE_SSE=1 server exit after .208894206s alive=no
+21
View File
@@ -0,0 +1,21 @@
{"ok":true,"runtime":"deno 2.9.7","ts":"6.0.3"}
{"url":"http://localhost:5199/api/v1/redact?token=[REDACTED]","apiKey":"[REDACTED]"}
HTTP/1.1 200 OK
cache-control: no-cache
connection: keep-alive
content-type: text/event-stream
transfer-encoding: chunked
date: Sun, 27 Sep 2026 10:03:06 GMT
event: tick
data: {"i":6}
id: 6
event: tick
data: {"i":7}
id: 7
event: tick
data: {"i":8}
id: 8
@@ -0,0 +1 @@
pg migrate: ["0001_init:Success","0002_foundation:Success","0003_instances:Success","0004_content:Success","0005_identity:Success","0006_sources:Success","0007_assets:Success","0008_policy:Success","0009_execution:Success","0010_agent:Success","0011_ops:Success","0012_organization:Success"] error: null
+14
View File
@@ -0,0 +1,14 @@
pg migrate: ["0001_init:Success","0002_index:Success"] error: null
pg rows: string:1/Dune/{"q":"1080p"}/true string:2/Arrival/null/true
pg tx error -> rollback
pg titles after tx: Dune,Arrival,tx-ok
pg 50 parallel queries on pool=4: 158ms
pg destroyed cleanly
exit=0
postgresjs migrate: ["0001_init:Success","0002_index:Success"] error: null
postgresjs rows: string:1/Dune/"{\"q\":\"1080p\"}"/true string:2/Arrival/null/true
postgresjs tx error -> rollback
postgresjs titles after tx: Dune,Arrival,tx-ok
postgresjs 50 parallel queries on pool=4: 170ms
postgresjs destroyed cleanly
exit=0
+2
View File
@@ -0,0 +1,2 @@
{"@timestamp":"2026-09-27T10:04:24.739Z","level":"INFO","message":"fetch \"https://x/rss?passkey=[REDACTED]\"","logger":"umt.worker","properties":{"url":"https://x/rss?passkey=[REDACTED]","token":"[REDACTED]","n":3}}
{"@timestamp":"2026-09-27T10:04:24.741Z","level":"WARN","message":"child context","logger":"umt.worker","properties":{"runId":"r1","apiKey":"[REDACTED]"}}
+28
View File
@@ -0,0 +1,28 @@
{
"parseArgs": {
"role": "api"
},
"statfs": {
"bsize": 4096,
"bavailGB": 1198
},
"os": {
"cpus": 96,
"freememMB": 114164,
"loadavg": 3
},
"memoryUsage": "rss,heapTotal,heapUsed,external,arrayBuffers",
"als": "req-1",
"crypto": "2d711642 36",
"worker": {
"echo": "from worker",
"pid": 1041177
},
"childKill": {
"code": 0,
"sig": null,
"out": "ready|got-TERM"
},
"execPath": "/usr/local/bin/deno"
}
process.on(SIGTERM) handler fired
+2
View File
@@ -0,0 +1,2 @@
{"count":40}
{"total":"40","runs":"40","a":"24","b":"16"}
+16
View File
@@ -0,0 +1,16 @@
{"runId":"76f4d2cd-7419-41b0-807d-756157fba672","status":"pending","version":"v1"}
{"status":"running","version":"v1","attempts":1,"error":null,"output":null}
{"workflowRunIds":["76f4d2cd-7419-41b0-807d-756157fba672"]}
{"status":"completed","version":"v1","attempts":3,"error":null,"output":{"sub":{"torrentHash":"hash-d1"},"copied":{"dest":"/lib/d1.mkv"},"signal":{"ok":true}}}
submit-download function completed
wait-download signal-wait completed
copy-file function completed
register function completed
retention sleep completed
cleanup function completed
76f4d2cd submit-download A version=v1
76f4d2cd copy-file A start
76f4d2cd copy-file A end
76f4d2cd register A version=v1
76f4d2cd cleanup A
{"runId":"76f4d2cd-7419-41b0-807d-756157fba672","status":"completed","version":"v1"}
+19
View File
@@ -0,0 +1,19 @@
run=1fdbe2ec-6952-4958-bcbb-50903dbdfbe1
{"workflowRunIds":["1fdbe2ec-6952-4958-bcbb-50903dbdfbe1"]}
kill -9 worker A pid=968976
1fdbe2ec submit-download A version=v1
1fdbe2ec copy-file A start
after 12x3s: {"status":"completed","version":"v1","attempts":4,"error":null,"output":{"sub":{"torrentHash":"hash-d2"},"copied":{"dest":"/lib/d2.mkv"},"signal":{"ok":true}}}
submit-download function completed
wait-download signal-wait completed
copy-file function running
copy-file function completed
register function completed
retention sleep completed
cleanup function completed
1fdbe2ec submit-download A version=v1
1fdbe2ec copy-file A start
1fdbe2ec copy-file B start
1fdbe2ec copy-file B end
1fdbe2ec register B version=v1
1fdbe2ec cleanup B
+5
View File
@@ -0,0 +1,5 @@
biz.side_effect_ledger
openworkflow.openworkflow_migrations
openworkflow.step_attempts
openworkflow.workflow_runs
openworkflow.workflow_signals
+19
View File
@@ -0,0 +1,19 @@
open operations at attach: []
[tool_start] inventory__query
[tool_end] inventory__query "{\"title\":\"Dune\",\"inLibrary\":false,\"versions\":[]}"
[tool_start] download__submit
>> lane.abort()
[operation_abort]
[tool_end] download__submit "aborted by signal"
[tool_start] bash
[tool_end] bash "Tool execution was cancelled before completion."
[run_end] aborted
abort result: {"ok":true,"value":{"operationId":"01a0e251-49a9-7098-9037-ced15867c0d4","steer":[],"followUp":[]}}
PROMPT RESULT {"ok":true,"value":{"operationId":"01a0e251-49a9-7098-9037-ced15867c0d4","kind":"run","status":"aborted","fromTipId":null,"tipId":"01a0e251-49c2-7098-9037-cee4bf55ca98","startedAt":1790503373225,"endedAt":1790503373567}}
SUBMITTED in this process [] fake calls: 2
user 把《Dune》1080p 收进来
assistant <thinking> | 我先查一下库里有没有《Dune》。 | toolCall(inventory__query)
toolResult inventory__query {"title":"Dune","inLibrary":false,"versions":[]}
assistant 库里没有,提交 1080p 下载;顺便试一下未授权的 bash。 | toolCall(download__submit) | toolCall(bash)
toolResult download__submit [isError] aborted by signal
toolResult bash [isError] Tool execution was cancelled before completion.
+5
View File
@@ -0,0 +1,5 @@
open operations at attach: []
[tool_start] inventory__query
[tool_end] inventory__query "{\"title\":\"Dune\",\"inLibrary\":false,\"versions\":[]}"
[tool_start] download__submit
>> SIGKILL self during download.submit
+39
View File
@@ -0,0 +1,39 @@
[run_start] lane=main
[turn_start] lane=main
[text_delta] "我先查一下库里有没有《D"
[text_delta] "une》。"
[toolcall_end] inventory__query
[usage] in=145 totals.input=145 output=18
[hook before_tool] inventory__query args={"title":"Dune","year":2021}
[tool_start] inventory__query {"title":"Dune","year":2021}
[tool_update] lane=main
[tool_end] inventory__query isError=false -> "{\"title\":\"Dune\",\"inLibrary\":false,\"versions\":[]}"
[turn_end] lane=main
[turn_start] lane=main
[text_delta] "库里没有,提交 1080p 下载"
[text_delta] ";顺便试一下未授权的 b"
[text_delta] "ash。"
[toolcall_end] download__submit
[toolcall_end] bash
[usage] in=40 totals.input=185 output=46
[hook before_tool] download__submit args={"title":"Dune","quality":"1080p"}
[tool_start] download__submit {"title":"Dune","quality":"1080p"}
[tool_end] download__submit isError=false -> "{\"accepted\":true,\"idempotencyKey\":\"dl:Dune:1080p\",\"status\":\"submitted\"}"
[tool_start] bash {"command":"rm -rf /"}
[tool_end] bash isError=true -> "Tool \"bash\" is unavailable"
[turn_end] lane=main
[turn_start] lane=main
[text_delta] "下载已提交(仅代表已提交"
[text_delta] ",不代表已入库):{\"a"
[text_delta] "ccepted\":tru"
[text_delta] "e,\"idempotencyKey\":\""
[text_delta] "dl:Dune:1080"
[text_delta] "p\",\"status\":\"sub"
[text_delta] "mitted\"};bash 结果 isE"
[text_delta] "rror=true。"
[usage] in=67 totals.input=252 output=75
[turn_end] lane=main
[run_end] status=completed
RESULT {"ok":true,"value":{"operationId":"01a0e251-372b-714d-8e79-7300ed37b0d8","kind":"run","status":"completed","fromTipId":null,"tipId":"01a0e251-3749-714d-8e79-731888a9468b","startedAt":1790503368491,"endedAt":1790503368525}}
SUBMITTED [{"key":"dl:Dune:1080p","title":"Dune"}] fake calls: 3
TRANSCRIPT roles: assistant,toolResult,toolResult,assistant,toolResult,assistant,user
+15
View File
@@ -0,0 +1,15 @@
found session crash-demo /opt/uhd_media_tool/spikes/deno/apps/server/src/pi/.sessions/--opt-uhd_media_tool-spikes-deno-apps-server-src-pi-.sessions--/2026-09-27T10-02-53-768Z_crash-demo.jsonl
open operations at attach: [{"lane":"main","operationId":"01a0e251-4bd4-7649-b068-537faf822316","kind":"run","startedAt":1790503373780}]
[tool_end] (recovery) download__submit "[Tool execution was interrupted. The preceding output is the latest durable progress snapshot; newer live output may be missing, and the external outcome is unknown.]"
[tool_start] (recovery) bash
[tool_end] (recovery) bash "Tool \"bash\" is unavailable"
[run_end] completed
RESUME RESULT {"ok":true,"value":{"operationId":"01a0e251-4bd4-7649-b068-537faf822316","kind":"run","status":"completed","fromTipId":null,"tipId":"01a0e251-4dfd-74a9-9dee-40c910a4e623","startedAt":1790503373780,"endedAt":1790503374346}}
SUBMITTED in this process [] fake calls: 1
user 把《Dune》1080p 收进来
assistant <thinking> | 我先查一下库里有没有《Dune》。 | toolCall(inventory__query)
toolResult inventory__query {"title":"Dune","inLibrary":false,"versions":[]}
assistant 库里没有,提交 1080p 下载;顺便试一下未授权的 bash。 | toolCall(download__submit) | toolCall(bash)
toolResult download__submit [isError] [Tool execution was interrupted. The preceding output is the latest durable progress snapshot; newer live output may be missing, and the external outcome is unknown.]
toolResult bash [isError] Tool "bash" is unavailable
assistant 下载已提交(仅代表已提交,不代表已入库):[Tool execution was interrupted. The preceding output is the latest durable progress snapshot; newer live output may be missing, and the external outcome is unknown.];bash 结果 isEr
+7
View File
@@ -0,0 +1,7 @@
custom-dest: {"level":30,"time":1790503464308,"pid":985225,"hostname":"DS4199","token":"[REDACTED]","url":"https://x/rss?passkey=[REDACTED]&a=1","n":1,"msg":"fetch https://x?api_key=[REDACTED]"}
{"level":40,"time":1790503464309,"pid":985225,"hostname":"DS4199","component":"worker","password":"p","err":{},"msg":"child"}
{"level":30,"time":1790503464311,"pid":985225,"hostname":"DS4199","cookie":"[REDACTED]","msg":"stdout default destination"}
{"level":30,"time":1790503464364,"pid":985225,"hostname":"DS4199","secret":"[REDACTED]","msg":"via pino.transport (worker_threads)"}
{"level":30,"time":1790503482109,"pid":987284,"hostname":"DS4199","secret":"[REDACTED]","msg":"via pino.transport (worker_threads)"}
[18:04:42] INFO (987284): via pino-pretty transport
token: "[REDACTED]"
+3
View File
@@ -0,0 +1,3 @@
import { a } from "./sloppy";
export const b = a;
export * from "./a";
+4
View File
@@ -0,0 +1,4 @@
已生成 app/lib/design-tokens.generated.ts(字号 36 / 缓动 5 / 圆角 10 / 时长 6)
design token 登记表是最新的(字号 36 / 缓动 5 / 圆角 10 / 时长 6)
check exit=0
设计 token 检查通过
+8
View File
@@ -0,0 +1,8 @@
packages/adapters: ts7 errors=0
packages/agent: ts7 errors=0
packages/contracts: ts7 errors=0
packages/db: ts7 errors=0
packages/domain: ts7 errors=0
packages/tasks: ts7 errors=0
packages/testkit: ts7 errors=0
apps/server: ts7 errors=0
+3
View File
@@ -0,0 +1,3 @@
tsconfig.json(13,5): error TS5102: Option 'baseUrl' has been removed. Please remove it from your configuration.
Use '"paths": {"*": ["./*"]}' instead.
exit=0
+11
View File
@@ -0,0 +1,11 @@
== deno check
real 0m2.574s
== tsc 7.0.2
Task tsgo deno run -A npm:[email protected]/tsc --noEmit -p tsconfig.tsc.json
real 0m1.525s
user 0m12.882s
sys 0m1.994s
exit=0
== logtape (npm) rerun
{"@timestamp":"2026-09-27T10:10:40.172Z","level":"INFO","message":"fetch \"https://x/rss?passkey=[REDACTED]\"","logger":"umt.worker","properties":{"url":"https://x/rss?passkey=[REDACTED]","token":"[REDACTED]","n":3}}
{"@timestamp":"2026-09-27T10:10:40.174Z","level":"WARN","message":"child context","logger":"umt.worker","properties":{"runId":"r1","apiKey":"[REDACTED]"}}
+12
View File
@@ -0,0 +1,12 @@
packages/adapters | deno: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ Test Files 1 failed | 8 passed (9) Tests 1 failed | 108 passed | 2 skipped (111) | node: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ Test Files 1 failed | 8 passed (9) Tests 1 failed | 108 passed | 2 skipped (111)
packages/agent | deno: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ Test Files 1 failed | 3 passed (4) Tests 1 failed | 26 passed (27) | node: Test Files 4 passed (4) Tests 27 passed (27)
packages/contracts | deno: Test Files 3 passed (3) Tests 361 passed (361) | node: Test Files 3 passed (3) Tests 361 passed (361)
packages/db | deno: Test Files 2 passed (2) Tests 11 passed (11) | node: Test Files 2 passed (2) Tests 11 passed (11)
packages/domain | deno: Test Files 2 passed (2) Tests 122 passed (122) | node: Test Files 2 passed (2) Tests 122 passed (122)
packages/tasks | deno: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ Test Files 3 failed | 2 passed (5) Tests 1 failed | 7 passed | 7 skipped (15) | node: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ Test Files 1 failed | 4 passed (5) Tests 1 failed | 14 passed (15)
packages/testkit | deno: Test Files 1 passed (1) Tests 1 passed (1) | node: Test Files 1 passed (1) Tests 1 passed (1)
# notes
# adapters: same 1 failure under node and deno (pre-existing, WIP by other agent)
# agent: deno-only failure = crash-recovery child spawned via process.execPath (=deno) hits extensionless import in packages/contracts/src/index.ts
# tasks: dev PG 55433 shared/unstable in both runtimes; rerun under deno with TASKS_TEST_DATABASE_URL=postgres://postgres:[email protected]:55435/umt_tasks -> Test Files 5 passed (5), Tests 15 passed (15) (incl. kill -9 crash-recovery)
+9
View File
@@ -0,0 +1,9 @@
RUN v5.0.2 /opt/uhd_media_tool/apps/server
Test Files 4 passed (4)
Tests 15 passed (15)
Start at 18:05:17
Duration 733ms (import 48%, transform 46%, tests 6%, worker 1%)
+27
View File
@@ -0,0 +1,27 @@
RUN v5.0.2 /opt/uhd_media_tool/spikes/deno/apps/server
Test Files 1 passed (1)
Tests 5 passed (5)
Start at 18:05:04
Duration 712ms (tests 80%, transform 15%, import 4%, worker 1%)
RUN v5.0.2 /opt/uhd_media_tool/spikes/deno/apps/server
Test Files 1 passed (1)
Tests 5 passed (5)
Start at 18:05:05
Duration 747ms (tests 79%, transform 16%, import 5%, worker 1%)
RUN v5.0.2 /opt/uhd_media_tool/spikes/deno/apps/server
Test Files 1 passed (1)
Tests 5 passed (5)
Start at 18:05:06
Duration 728ms (tests 79%, transform 16%, import 4%, worker 1%)
+28
View File
@@ -0,0 +1,28 @@
build/server/assets/space-grotesk-latin-ext-wght-normal-D9tNdqV9.woff2 18.94 kB
build/server/assets/inter-tight-greek-wght-normal-sS1B39LI.woff2 22.19 kB
build/server/assets/space-grotesk-latin-wght-normal-BhU9QXUp.woff2 22.29 kB
build/server/assets/inter-tight-cyrillic-ext-wght-normal-okcGN4Nq.woff2 26.66 kB
build/server/assets/inter-tight-latin-wght-normal-DX-nOvPD.woff2 44.87 kB
build/server/assets/inter-tight-latin-ext-wght-normal-B4u9PlvR.woff2 89.80 kB
build/server/assets/server-build-DhK8dDjm.css 90.20 kB
build/server/index.js 21.63 kB
✓ 11 assets cleaned from React Router server build.
build/client/assets/inter-tight-cyrillic-ext-wght-normal-okcGN4Nq.woff2
build/client/assets/inter-tight-cyrillic-wght-normal-BS41q_Tv.woff2
build/client/assets/inter-tight-greek-ext-wght-normal-DQgQPNk4.woff2
build/client/assets/inter-tight-greek-wght-normal-sS1B39LI.woff2
build/client/assets/inter-tight-vietnamese-wght-normal-TKKdAiBA.woff2
build/client/assets/inter-tight-latin-ext-wght-normal-B4u9PlvR.woff2
build/client/assets/inter-tight-latin-wght-normal-DX-nOvPD.woff2
build/client/assets/space-grotesk-vietnamese-wght-normal-D0rl6rjA.woff2
build/client/assets/space-grotesk-latin-ext-wght-normal-D9tNdqV9.woff2
build/client/assets/space-grotesk-latin-wght-normal-BhU9QXUp.woff2
build/server/assets/server-build-DhK8dDjm.css
SPA Mode: Generated build/client/index.html
Removing the server build in /opt/uhd_media_tool/spikes/deno/web/build/server due to ssr:false
✓ built in 690ms
SPA Mode: Generated build/client/index.html
Removing the server build in /opt/uhd_media_tool/spikes/deno/web/build/server due to ssr:false
✓ built in 421ms
+68
View File
@@ -0,0 +1,68 @@
[tsconfig-paths] An error occurred while parsing "/opt/uhd_media_tool/spikes/deno/packages/contracts/tsconfig.json". See below for details. To disable this message, set the `ignoreConfigErrors` option to true.
TSConfckParseError: failed to resolve "extends":"../../tsconfig.base.json" in /opt/uhd_media_tool/spikes/deno/packages/contracts/tsconfig.json
at resolveExtends (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/[email protected]/node_modules/tsconfck/src/parse.js:262:8)
at parseExtends (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/[email protected]/node_modules/tsconfck/src/parse.js:197:24)
at Module.parse (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/[email protected]/node_modules/tsconfck/src/parse.js:54:23)
at async Promise.all (index 10)
at BasicMinimalPluginContext.configResolved (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/[email protected]/node_modules/vite-tsconfig-paths/src/index.ts:106:9)
at async Promise.all (index 1)
at resolveConfig (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/[email protected]/node_modules/vite/dist/node/chunks/config.js:35762:2)
at _createServer (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/[email protected]/node_modules/vite/dist/node/chunks/config.js:25441:67)
at dev (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/@[email protected]/node_modules/@react-router/dev/dist/cli/index.js:2104:16)
at dev2 (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/@[email protected]/node_modules/@react-router/dev/dist/cli/index.js:2275:3) {
code: "EXTENDS_RESOLVE",
tsconfigFile: "/opt/uhd_media_tool/spikes/deno/packages/contracts/tsconfig.json",
cause: Error: Cannot find module '../../tsconfig.base.json'
Require stack:
- /opt/uhd_media_tool/spikes/deno/packages/contracts/tsconfig.json
at Module._resolveFilename (node:module:4:14)
at nextResolve (node:module:1:13073)
at wrappedNext (node:module:1:13273)
at Ie (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/@[email protected]/node_modules/@tailwindcss/node/dist/index.mjs:1:539)
at nextResolve (node:module:1:13299)
at executeResolveHookChain (node:module:1:13643)
at Module._resolveFilename (node:module:1:25781)
at require.resolve (node:module:5:3624)
at resolveExtends (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/[email protected]/node_modules/tsconfck/src/parse.js:250:14)
at parseExtends (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/[email protected]/node_modules/tsconfck/src/parse.js:197:24) {
code: "MODULE_NOT_FOUND",
requireStack: [
"/opt/uhd_media_tool/spikes/deno/packages/contracts/tsconfig.json"
]
}
}
[tsconfig-paths] An error occurred while parsing "/opt/uhd_media_tool/spikes/deno/packages/contracts/tsconfig.json". See below for details. To disable this message, set the `ignoreConfigErrors` option to true.
TSConfckParseError: failed to resolve "extends":"../../tsconfig.base.json" in /opt/uhd_media_tool/spikes/deno/packages/contracts/tsconfig.json
at resolveExtends (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/[email protected]/node_modules/tsconfck/src/parse.js:262:8)
at parseExtends (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/[email protected]/node_modules/tsconfck/src/parse.js:197:24)
at Module.parse (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/[email protected]/node_modules/tsconfck/src/parse.js:54:23)
at async Promise.all (index 10)
at BasicMinimalPluginContext.configResolved (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/[email protected]/node_modules/vite-tsconfig-paths/src/index.ts:106:9)
at async Promise.all (index 1)
at resolveConfig (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/[email protected]/node_modules/vite/dist/node/chunks/config.js:35762:2)
at _createServer (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/[email protected]/node_modules/vite/dist/node/chunks/config.js:25441:67)
at BasicMinimalPluginContext.configResolved (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/@[email protected]/node_modules/@react-router/dev/dist/vite.js:3405:29)
at async Promise.all (index 2) {
code: "EXTENDS_RESOLVE",
tsconfigFile: "/opt/uhd_media_tool/spikes/deno/packages/contracts/tsconfig.json",
cause: Error: Cannot find module '../../tsconfig.base.json'
Require stack:
- /opt/uhd_media_tool/spikes/deno/packages/contracts/tsconfig.json
at Module._resolveFilename (node:module:4:14)
at nextResolve (node:module:1:13073)
at wrappedNext (node:module:1:13273)
at Ie (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/@[email protected]/node_modules/@tailwindcss/node/dist/index.mjs:1:539)
at nextResolve (node:module:1:13299)
at executeResolveHookChain (node:module:1:13643)
at Module._resolveFilename (node:module:1:25781)
at require.resolve (node:module:5:3624)
at resolveExtends (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/[email protected]/node_modules/tsconfck/src/parse.js:250:14)
at parseExtends (/opt/uhd_media_tool/spikes/deno/node_modules/.deno/[email protected]/node_modules/tsconfck/src/parse.js:197:24) {
code: "MODULE_NOT_FOUND",
requireStack: [
"/opt/uhd_media_tool/spikes/deno/packages/contracts/tsconfig.json"
]
}
}
➜ Local: http://127.0.0.1:5181/
➜ Network: use --host to expose
+8
View File
@@ -0,0 +1,8 @@
GET / -> 200 228202B
<title>UHD 媒体自动化控制台</title>
GET /app/root.tsx -> 200 text/javascript
GET /app/app.css -> 200
[npm:vite dev] GET / -> 200 228202B
<title>UHD 媒体自动化控制台</title>
GET /app/root.tsx -> 200 text/javascript
GET /app/api/health.ts -> 200
@@ -0,0 +1,7 @@
VITE v7.3.6 ready in 561 ms
➜ Local: http://127.0.0.1:5181/
➜ Network: use --host to expose
✘ [ERROR] The build was canceled
+8
View File
@@ -0,0 +1,8 @@
[A] worker started pid=968976 version=v1 runtime=deno 2.9.7
[A] run=76f4d2cd step=submit-download version=v1
[A] run=76f4d2cd step=copy-file start
[A] run=76f4d2cd step=copy-file end
[A] run=76f4d2cd step=register version=v1
[A] run=76f4d2cd step=cleanup
[A] run=1fdbe2ec step=submit-download version=v1
[A] run=1fdbe2ec step=copy-file start
+26
View File
@@ -0,0 +1,26 @@
[A] worker started pid=974721 version=v1 runtime=deno 2.9.7
[A] run=0ef64009 step=work
[A] run=db4b002d step=work
[A] run=59c3f0a3 step=work
[A] run=753c094f step=work
[A] run=322c2a2e step=work
[A] run=85126efd step=work
[A] run=24bd06f4 step=work
[A] run=d1635912 step=work
[A] run=1d5b5a33 step=work
[A] run=ac3a2ef4 step=work
[A] run=ca92602e step=work
[A] run=70e3a89b step=work
[A] run=f82c62f0 step=work
[A] run=832cdd09 step=work
[A] run=33d05100 step=work
[A] run=e83b537d step=work
[A] run=cf8a4c6c step=work
[A] run=030d8db0 step=work
[A] run=85854e33 step=work
[A] run=67f86be7 step=work
[A] run=0b99c858 step=work
[A] run=542354a5 step=work
[A] run=818f3e55 step=work
[A] run=a4739536 step=work
[A] worker stopped gracefully
+22
View File
@@ -0,0 +1,22 @@
[B] worker started pid=970631 version=v1 runtime=deno 2.9.7
[B] run=1fdbe2ec step=copy-file start
[B] run=1fdbe2ec step=copy-file end
[B] run=1fdbe2ec step=register version=v1
[B] run=1fdbe2ec step=cleanup
[B] run=a6855da5 step=work
[B] run=3306be9c step=work
[B] run=5400dd52 step=work
[B] run=7d9932a2 step=work
[B] run=356cd393 step=work
[B] run=493d6178 step=work
[B] run=4aed4f53 step=work
[B] run=89194738 step=work
[B] run=42285c79 step=work
[B] run=981eb93b step=work
[B] run=a929d9fd step=work
[B] run=4db4dc54 step=work
[B] run=3e1a7577 step=work
[B] run=b5c77ed8 step=work
[B] run=3dcac3f6 step=work
[B] run=9fefd4e0 step=work
[B] worker stopped gracefully
+5
View File
@@ -0,0 +1,5 @@
{
"name": "@umt/contracts",
"version": "0.0.0",
"exports": { ".": "./src/index.ts" }
}
@@ -0,0 +1,16 @@
{
"name": "@umt/contracts",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "vitest run"
},
"dependencies": {
"zod": "^4.3.6"
}
}
+284
View File
@@ -0,0 +1,284 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
// 人工/AI 域:Agent 运行、证据/计划产物、待处理事项、审批、持续授权(REQ §10、§11.3、§11.4)
export const AgentTrigger = z.enum(["user", "event", "schedule", "subagent"]);
export type AgentTrigger = z.infer<typeof AgentTrigger>;
export const AgentRunStatus = z.enum([
"queued",
"running",
"waiting",
"succeeded",
"failed",
"cancelled",
"budget_exceeded",
]);
export type AgentRunStatus = z.infer<typeof AgentRunStatus>;
/** 单次运行预算(调用次数/token/时长/费用,REQ §10.5) */
export const AgentBudget = z.object({
maxToolCalls: z.number().int().positive().optional(),
maxTokens: z.number().int().positive().optional(),
maxDurationSec: z.number().int().positive().optional(),
maxCostUsd: z.number().nonnegative().optional(),
});
export type AgentBudget = z.infer<typeof AgentBudget>;
export const AgentUsage = z.object({
inputTokens: NonNegInt.optional(),
outputTokens: NonNegInt.optional(),
cacheReadTokens: NonNegInt.optional(),
cacheWriteTokens: NonNegInt.optional(),
costUsd: z.number().nonnegative().optional(),
durationMs: NonNegInt.optional(),
});
export type AgentUsage = z.infer<typeof AgentUsage>;
export const AgentRun = EntityBase.extend({
taskId: Id.nullable(),
parentRunId: Id.nullable(),
trigger: AgentTrigger,
goal: z.string().min(1),
status: AgentRunStatus,
provider: z.string().nullable(),
model: z.string().nullable(),
thinkingLevel: z.string().nullable(),
/** 授权范围(能力白名单、对象范围);子代理继承父任务上限 */
scope: JsonObject,
budget: AgentBudget,
usage: AgentUsage,
evidenceVersion: z.string().nullable(),
/** Pi 会话持久化状态;不对普通列表返回 */
sessionState: JsonObject.nullable(),
summary: z.string().nullable(),
toolCallCount: NonNegInt,
lastEventSeq: NonNegInt,
error: JsonObject.nullable(),
cancelRequestedAt: Timestamp.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
createdBy: z.string().nullable(),
});
export type AgentRun = z.infer<typeof AgentRun>;
export const AgentRunCreate = AgentRun.pick({
taskId: true,
parentRunId: true,
trigger: true,
goal: true,
scope: true,
budget: true,
}).partial({ taskId: true, parentRunId: true, trigger: true, scope: true, budget: true });
export type AgentRunCreate = z.infer<typeof AgentRunCreate>;
export const AgentRunUpdate = AgentRun.pick({
status: true,
provider: true,
model: true,
thinkingLevel: true,
usage: true,
evidenceVersion: true,
sessionState: true,
summary: true,
toolCallCount: true,
lastEventSeq: true,
error: true,
startedAt: true,
finishedAt: true,
}).partial();
export type AgentRunUpdate = z.infer<typeof AgentRunUpdate>;
export const ArtifactKind = z.enum(["evidence", "plan", "proposal", "report", "snapshot", "log"]);
export type ArtifactKind = z.infer<typeof ArtifactKind>;
/** 证据/计划/提案产物:提案不直接晋升为事实 */
export const Artifact = EntityBase.extend({
kind: ArtifactKind,
agentRunId: Id.nullable(),
taskId: Id.nullable(),
subjectType: z.string().nullable(),
subjectId: Id.nullable(),
title: z.string().min(1),
mimeType: z.string(),
content: JsonObject,
contentHash: z.string().min(1),
sizeBytes: NonNegInt.nullable(),
expiresAt: Timestamp.nullable(),
});
export type Artifact = z.infer<typeof Artifact>;
/** contentHash / sizeBytes 由服务端计算 */
export const ArtifactCreate = Artifact.omit({ ...SERVER_MANAGED, contentHash: true, sizeBytes: true }).partial({
agentRunId: true,
taskId: true,
subjectType: true,
subjectId: true,
mimeType: true,
expiresAt: true,
});
export type ArtifactCreate = z.infer<typeof ArtifactCreate>;
/** 产物内容不可变;只能调整过期时间 */
export const ArtifactUpdate = z.object({ expiresAt: Timestamp.nullable() });
export type ArtifactUpdate = z.infer<typeof ArtifactUpdate>;
export const DecisionKind = z.enum([
"identity_ambiguous",
"download_stalled",
"download_failed",
"path_conflict",
"space_insufficient",
"approval_required",
"metadata_conflict",
"source_unreachable",
"other",
]);
export type DecisionKind = z.infer<typeof DecisionKind>;
export const DecisionStatus = z.enum(["open", "investigating", "awaiting_user", "resolved", "dismissed", "expired"]);
export type DecisionStatus = z.infer<typeof DecisionStatus>;
export const Severity = z.enum(["info", "warning", "critical"]);
export type Severity = z.infer<typeof Severity>;
export const DecisionOption = z.object({
key: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
/** 选择后执行的能力与参数(仍经授权校验) */
capabilityId: z.string().optional(),
input: JsonObject.optional(),
});
export type DecisionOption = z.infer<typeof DecisionOption>;
/** 待处理中心条目;同一 dedupeKey 同时只有一个未关闭条目 */
export const Decision = EntityBase.extend({
kind: DecisionKind,
status: DecisionStatus,
severity: Severity,
title: z.string().min(1),
summary: z.string().nullable(),
subjectType: z.string().nullable(),
subjectId: Id.nullable(),
dedupeKey: z.string().min(1),
evidence: JsonObject,
options: z.array(DecisionOption),
resolution: JsonObject.nullable(),
taskId: Id.nullable(),
agentRunId: Id.nullable(),
attempts: NonNegInt,
nextReviewAt: Timestamp.nullable(),
resolvedBy: z.string().nullable(),
resolvedAt: Timestamp.nullable(),
});
export type Decision = z.infer<typeof Decision>;
export const DecisionCreate = Decision.pick({
kind: true,
severity: true,
title: true,
summary: true,
subjectType: true,
subjectId: true,
dedupeKey: true,
evidence: true,
options: true,
taskId: true,
agentRunId: true,
nextReviewAt: true,
}).partial({
severity: true,
summary: true,
subjectType: true,
subjectId: true,
evidence: true,
options: true,
taskId: true,
agentRunId: true,
nextReviewAt: true,
});
export type DecisionCreate = z.infer<typeof DecisionCreate>;
export const DecisionUpdate = Decision.pick({
status: true,
severity: true,
summary: true,
evidence: true,
options: true,
agentRunId: true,
nextReviewAt: true,
}).partial();
export type DecisionUpdate = z.infer<typeof DecisionUpdate>;
/** 用户处理待处理事项:选一个选项或关闭 */
export const DecisionResolveInput = z.object({
optionKey: z.string().min(1).optional(),
dismiss: z.boolean().optional(),
note: z.string().max(2000).optional(),
});
export type DecisionResolveInput = z.infer<typeof DecisionResolveInput>;
export const ApprovalStatus = z.enum(["pending", "approved", "rejected", "revoked", "expired", "consumed"]);
export type ApprovalStatus = z.infer<typeof ApprovalStatus>;
/** 单次审批:绑定 planType+planId + payloadHash + expectedRevision;换候选/路径后旧审批失效 */
export const Approval = EntityBase.extend({
planType: z.string().min(1),
planId: Id,
payloadHash: z.string().min(1),
expectedRevision: z.number().int().min(0),
scope: JsonObject,
status: ApprovalStatus,
decisionId: Id.nullable(),
requestedBy: z.string().min(1),
decidedBy: z.string().nullable(),
decidedAt: Timestamp.nullable(),
expiresAt: Timestamp.nullable(),
reason: z.string().nullable(),
});
export type Approval = z.infer<typeof Approval>;
export const ApprovalCreate = Approval.pick({
planType: true,
planId: true,
payloadHash: true,
expectedRevision: true,
scope: true,
decisionId: true,
requestedBy: true,
expiresAt: true,
reason: true,
}).partial({ scope: true, decisionId: true, expiresAt: true, reason: true });
export type ApprovalCreate = z.infer<typeof ApprovalCreate>;
/** 批准/拒绝时回传 payloadHash,服务端比对防止审批被挪用到变更后的计划 */
export const ApprovalUpdate = z.object({
status: z.enum(["approved", "rejected", "revoked"]),
payloadHash: z.string().min(1),
reason: z.string().max(2000).optional(),
});
export type ApprovalUpdate = z.infer<typeof ApprovalUpdate>;
export const GrantStatus = z.enum(["active", "revoked", "expired"]);
export type GrantStatus = z.infer<typeof GrantStatus>;
/** 持续授权:绑定策略版本、动作、对象范围与预算 */
export const AutomationGrant = EntityBase.extend({
action: z.string().min(1),
policyRevisionId: Id.nullable(),
scope: JsonObject,
budget: JsonObject,
status: GrantStatus,
grantedBy: z.string().min(1),
reason: z.string().nullable(),
expiresAt: Timestamp.nullable(),
revokedAt: Timestamp.nullable(),
revokedBy: z.string().nullable(),
});
export type AutomationGrant = z.infer<typeof AutomationGrant>;
export const AutomationGrantCreate = AutomationGrant.pick({
action: true,
policyRevisionId: true,
scope: true,
budget: true,
reason: true,
expiresAt: true,
}).partial({ policyRevisionId: true, scope: true, budget: true, reason: true, expiresAt: true });
export type AutomationGrantCreate = z.infer<typeof AutomationGrantCreate>;
/** 只能撤销或缩短有效期,不能就地扩大范围(扩大 = 新授权) */
export const AutomationGrantUpdate = z.object({
status: z.literal("revoked").optional(),
expiresAt: Timestamp.nullable().optional(),
reason: z.string().max(2000).optional(),
});
export type AutomationGrantUpdate = z.infer<typeof AutomationGrantUpdate>;
+783
View File
@@ -0,0 +1,783 @@
import { z } from "zod";
import * as agent from "./agent";
import * as assets from "./assets";
import { CapabilityDescriptor, KnownCapabilityId } from "./capability";
import { type ApiScope, CursorQuery, cursorPage, Id, IdParam } from "./common";
import * as content from "./content";
import * as execution from "./execution";
import * as identity from "./identity";
import * as instances from "./instances";
import * as ops from "./ops";
import * as organization from "./organization";
import * as policy from "./policy";
import { GlobalSettings, GlobalSettingsUpdate } from "./settings";
import * as sources from "./sources";
// 所有 REST 端点的路径常量与请求/响应 schema 汇总。
// path 相对 API_PREFIX,参数用 OpenAPI 风格 `{id}`(server 的 createRoute 直接使用);
// web 用 buildPath() 填参数。响应体统一套 apiResponse() 信封,这里的 response 是 data 部分。
export const API_PREFIX = "/api/v1";
export type HttpMethod = "get" | "post" | "patch" | "put" | "delete";
export interface EndpointDef {
method: HttpMethod;
path: string;
scope: ApiScope;
summary: string;
params?: z.ZodObject;
query?: z.ZodType;
body?: z.ZodType;
response: z.ZodType;
}
const ep = <E extends EndpointDef>(e: E): E => e;
/** 列表查询:游标分页 + 资源自有过滤 */
const listQuery = <S extends z.ZodRawShape>(filters: S) => CursorQuery.extend(filters);
/** 删除/无内容动作的响应 */
export const OkResult = z.object({ ok: z.literal(true) });
interface CrudSchemas {
row: z.ZodType;
create?: z.ZodType;
update?: z.ZodType;
filters?: z.ZodRawShape;
}
/** 标准资源端点:list / get / create / update / remove(按给出的 schema 决定是否生成) */
function crud<const B extends string>(
base: B,
name: string,
s: CrudSchemas,
scopes: { read: ApiScope; write: ApiScope },
) {
const item = `${base}/{id}` as const;
return {
list: ep({
method: "get",
path: base,
scope: scopes.read,
summary: `${name}列表`,
query: listQuery(s.filters ?? {}),
response: cursorPage(s.row),
}),
get: ep({
method: "get",
path: item,
scope: scopes.read,
summary: `${name}详情`,
params: IdParam,
response: s.row,
}),
...(s.create
? {
create: ep({
method: "post",
path: base,
scope: scopes.write,
summary: `新建${name}`,
body: s.create,
response: s.row,
}),
}
: {}),
...(s.update
? {
update: ep({
method: "patch",
path: item,
scope: scopes.write,
summary: `修改${name}`,
params: IdParam,
body: s.update,
response: s.row,
}),
}
: {}),
};
}
const R = { read: "read", write: "admin" } as const;
const del = (path: string, summary: string) =>
ep({ method: "delete", path, scope: "admin", summary, params: IdParam, response: OkResult });
export const Api = {
// ── 内容 / 身份 ──
mediaEntities: {
...crud(
"/media-entities",
"作品",
{
row: content.MediaEntity,
create: content.MediaEntityCreate,
update: content.MediaEntityUpdate,
filters: {
kind: content.MediaKind.optional(),
status: content.MediaEntityStatus.optional(),
q: z.string().max(200).optional(),
},
},
R,
),
merge: ep({
method: "post",
path: "/media-entities/{id}/merge",
scope: "admin",
summary: "合并重复作品(保留旧 ID 跳转)",
params: IdParam,
body: z.object({ intoId: Id, reason: z.string().max(2000) }),
response: content.MediaEntity,
}),
},
contentUnits: crud(
"/content-units",
"内容单元",
{
row: content.ContentUnit,
create: content.ContentUnitCreate,
update: content.ContentUnitUpdate,
filters: { mediaEntityId: Id.optional(), seasonNumber: z.coerce.number().int().optional() },
},
R,
),
titleAliases: {
...crud(
"/title-aliases",
"别名",
{
row: content.TitleAlias,
create: content.TitleAliasCreate,
update: content.TitleAliasUpdate,
filters: { mediaEntityId: Id.optional(), q: z.string().max(200).optional() },
},
R,
),
remove: del("/title-aliases/{id}", "删除别名"),
},
externalIdentities: {
...crud(
"/external-identities",
"外部身份",
{
row: identity.ExternalIdentity,
create: identity.ExternalIdentityCreate,
update: identity.ExternalIdentityUpdate,
filters: {
mediaEntityId: Id.optional(),
provider: z.string().optional(),
status: identity.IdentityStatus.optional(),
},
},
R,
),
revisions: ep({
method: "get",
path: "/external-identities/{id}/revisions",
scope: "read",
summary: "身份修订历史",
params: IdParam,
response: z.object({ items: z.array(identity.IdentityRevision) }),
}),
},
// ── 文件 ──
mediaAssets: crud(
"/media-assets",
"资产",
{
row: assets.MediaAsset,
update: assets.MediaAssetUpdate,
filters: { mediaEntityId: Id.optional(), status: assets.AssetStatus.optional() },
},
R,
),
assetUnitCoverage: crud(
"/asset-unit-coverage",
"资产覆盖",
{
row: assets.AssetUnitCoverage,
create: assets.AssetUnitCoverageCreate,
update: assets.AssetUnitCoverageUpdate,
filters: { mediaAssetId: Id.optional(), contentUnitId: Id.optional() },
},
R,
),
assetLocations: crud(
"/asset-locations",
"资产位置",
{
row: assets.AssetLocation,
update: assets.AssetLocationUpdate,
filters: { mediaAssetId: Id.optional(), status: assets.LocationStatus.optional() },
},
R,
),
pathBindings: crud(
"/path-bindings",
"路径绑定",
{
row: assets.PathBinding,
create: assets.PathBindingCreate,
update: assets.PathBindingUpdate,
filters: { mediaEntityId: Id.optional(), status: assets.BindingStatus.optional() },
},
R,
),
// ── 实例 ──
libraryRoots: crud(
"/library-roots",
"媒体库根",
{
row: instances.LibraryRoot,
create: instances.LibraryRootCreate,
update: instances.LibraryRootUpdate,
},
R,
),
downloaderInstances: {
...crud(
"/downloader-instances",
"下载器实例",
{
row: instances.DownloaderInstance,
create: instances.DownloaderInstanceCreate,
update: instances.DownloaderInstanceUpdate,
},
R,
),
test: ep({
method: "post",
path: "/downloader-instances/{id}/test",
scope: "admin",
summary: "测试下载器连接",
params: IdParam,
response: z.object({ ok: z.boolean(), version: z.string().nullable(), message: z.string().nullable() }),
}),
},
mediaServerInstances: crud(
"/media-server-instances",
"媒体服务器实例",
{
row: instances.MediaServerInstance,
create: instances.MediaServerInstanceCreate,
update: instances.MediaServerInstanceUpdate,
},
R,
),
pathMappings: {
...crud(
"/path-mappings",
"路径映射",
{
row: instances.PathMapping,
create: instances.PathMappingCreate,
update: instances.PathMappingUpdate,
},
R,
),
verify: ep({
method: "post",
path: "/path-mappings/{id}/verify",
scope: "admin",
summary: "校验路径映射可读写与挂载健康",
params: IdParam,
response: instances.PathMapping,
}),
remove: del("/path-mappings/{id}", "删除路径映射"),
},
// ── 来源 ──
sites: crud("/sites", "站点", { row: sources.Site, create: sources.SiteCreate, update: sources.SiteUpdate }, R),
siteAccounts: crud(
"/site-accounts",
"站点账号",
{
row: sources.SiteAccount,
create: sources.SiteAccountCreate,
update: sources.SiteAccountUpdate,
filters: { siteId: Id.optional() },
},
R,
),
feedSources: {
...crud(
"/feed-sources",
"Feed",
{
row: sources.FeedSource,
create: sources.FeedSourceCreate,
update: sources.FeedSourceUpdate,
filters: { siteId: Id.optional(), health: sources.FeedHealth.optional() },
},
R,
),
fetchNow: ep({
method: "post",
path: "/feed-sources/{id}/fetch",
scope: "admin",
summary: "立即抓取(仍受站点限流)",
params: IdParam,
response: z.object({ taskId: Id }),
}),
},
sourceReleases: crud(
"/source-releases",
"站点资源",
{
row: sources.SourceRelease,
filters: {
siteId: Id.optional(),
mediaEntityId: Id.optional(),
resolutionStatus: sources.ResolutionStatus.optional(),
q: z.string().max(200).optional(),
},
},
R,
),
observations: crud(
"/observations",
"观察",
{
row: sources.Observation,
filters: { feedId: Id.optional(), status: sources.ObservationStatus.optional() },
},
R,
),
// ── 规则 ──
subscriptions: crud(
"/subscriptions",
"订阅",
{
row: policy.Subscription,
create: policy.SubscriptionCreate,
update: policy.SubscriptionUpdate,
filters: { status: policy.SubscriptionStatus.optional(), mediaEntityId: Id.optional() },
},
R,
),
policyRevisions: {
...crud(
"/policy-revisions",
"策略版本",
{
row: policy.PolicyRevision,
create: policy.PolicyRevisionCreate,
update: policy.PolicyRevisionUpdate,
filters: { policyKey: z.string().optional(), status: policy.PolicyStatus.optional() },
},
R,
),
preview: ep({
method: "post",
path: "/policy-revisions/{id}/preview",
scope: "read",
summary: "历史回放预览",
params: IdParam,
response: policy.PolicyRevision,
}),
publish: ep({
method: "post",
path: "/policy-revisions/{id}/publish",
scope: "admin",
summary: "发布策略版本",
params: IdParam,
body: z.object({ expectedRevision: z.number().int().min(1) }),
response: policy.PolicyRevision,
}),
rollback: ep({
method: "post",
path: "/policy-revisions/{id}/rollback",
scope: "admin",
summary: "回滚到该版本",
params: IdParam,
response: policy.PolicyRevision,
}),
},
acquisitionSlots: crud(
"/acquisition-slots",
"收录目标",
{
row: policy.AcquisitionSlot,
create: policy.AcquisitionSlotCreate,
update: policy.AcquisitionSlotUpdate,
filters: { mediaEntityId: Id.optional(), status: policy.SlotStatus.optional() },
},
R,
),
preferenceTags: {
...crud(
"/preference-tags",
"偏好标签",
{
row: policy.PreferenceTag,
create: policy.PreferenceTagCreate,
update: policy.PreferenceTagUpdate,
filters: { status: policy.PreferenceStatus.optional(), kind: policy.PreferenceKind.optional() },
},
R,
),
/** 删除 = 标记 deleted + 写抑制记录 */
remove: del("/preference-tags/{id}", "删除偏好标签(留抑制记录)"),
},
preferenceSuppressions: crud(
"/preference-suppressions",
"偏好抑制",
{
row: policy.PreferenceSuppression,
create: policy.PreferenceSuppressionCreate,
update: policy.PreferenceSuppressionUpdate,
filters: { active: z.enum(["true", "false"]).optional() },
},
R,
),
// ── 执行 ──
tasks: {
...crud(
"/tasks",
"任务",
{
row: execution.Task,
filters: {
status: execution.TaskStatus.optional(),
kind: execution.TaskKind.optional(),
subjectId: Id.optional(),
},
},
R,
),
cancel: ep({
method: "post",
path: "/tasks/{id}/cancel",
scope: "organize.execute",
summary: "取消任务(停止未来步骤)",
params: IdParam,
body: execution.TaskCancelRequest,
response: execution.Task,
}),
retry: ep({
method: "post",
path: "/tasks/{id}/retry",
scope: "organize.execute",
summary: "重试失败任务",
params: IdParam,
response: execution.Task,
}),
operations: ep({
method: "get",
path: "/tasks/{id}/operations",
scope: "read",
summary: "任务的操作账本(业务时间线)",
params: IdParam,
response: z.object({ items: z.array(execution.AssetOperation) }),
}),
},
acquisitionPlans: crud(
"/acquisition-plans",
"下载计划",
{
row: execution.AcquisitionPlan,
filters: { slotId: Id.optional(), status: execution.PlanStatus.optional() },
},
R,
),
downloadTasks: {
...crud(
"/download-tasks",
"下载任务",
{
row: execution.DownloadTask,
filters: {
instanceId: Id.optional(),
status: execution.DownloadStatus.optional(),
managed: z.enum(["true", "false"]).optional(),
},
},
R,
),
pause: ep({
method: "post",
path: "/download-tasks/{id}/pause",
scope: "organize.execute",
summary: "暂停(记为用户暂停,阈值恢复不会自动恢复)",
params: IdParam,
response: execution.DownloadTask,
}),
resume: ep({
method: "post",
path: "/download-tasks/{id}/resume",
scope: "organize.execute",
summary: "恢复下载",
params: IdParam,
response: execution.DownloadTask,
}),
},
// ── 人工 / AI ──
decisions: {
...crud(
"/decisions",
"待处理事项",
{
row: agent.Decision,
filters: { status: agent.DecisionStatus.optional(), kind: agent.DecisionKind.optional() },
},
R,
),
resolve: ep({
method: "post",
path: "/decisions/{id}/resolve",
scope: "organize.execute",
summary: "处理待处理事项",
params: IdParam,
body: agent.DecisionResolveInput,
response: agent.Decision,
}),
},
approvals: crud(
"/approvals",
"审批",
{
row: agent.Approval,
update: agent.ApprovalUpdate,
filters: { status: agent.ApprovalStatus.optional(), planId: Id.optional() },
},
{ read: "read", write: "organize.execute" },
),
automationGrants: crud(
"/automation-grants",
"持续授权",
{
row: agent.AutomationGrant,
create: agent.AutomationGrantCreate,
update: agent.AutomationGrantUpdate,
filters: { status: agent.GrantStatus.optional() },
},
R,
),
agentRuns: {
...crud(
"/agent-runs",
"Agent 运行",
{
row: agent.AgentRun,
create: agent.AgentRunCreate,
filters: { status: agent.AgentRunStatus.optional(), taskId: Id.optional() },
},
{ read: "read", write: "organize.execute" },
),
cancel: ep({
method: "post",
path: "/agent-runs/{id}/cancel",
scope: "organize.execute",
summary: "取消 Agent 运行",
params: IdParam,
response: agent.AgentRun,
}),
message: ep({
method: "post",
path: "/agent-runs/{id}/messages",
scope: "organize.execute",
summary: "向运行中的会话追加用户消息(纠正/补充)",
params: IdParam,
body: z.object({ text: z.string().min(1).max(8000) }),
response: agent.AgentRun,
}),
},
artifacts: crud(
"/artifacts",
"产物",
{
row: agent.Artifact,
filters: { agentRunId: Id.optional(), taskId: Id.optional(), kind: agent.ArtifactKind.optional() },
},
R,
),
// ── 运维 ──
reconcileRuns: crud(
"/reconcile-runs",
"校验运行",
{
row: ops.ReconcileRun,
create: ops.ReconcileRunCreate,
filters: { kind: ops.ReconcileKind.optional(), status: ops.ReconcileStatus.optional() },
},
R,
),
serverMappings: crud(
"/server-mappings",
"库存映射",
{
row: ops.ServerMapping,
update: ops.ServerMappingUpdate,
filters: {
mediaServerInstanceId: Id.optional(),
mediaEntityId: Id.optional(),
matchStatus: ops.ServerMatchStatus.optional(),
},
},
R,
),
apiKeys: {
list: ep({
method: "get",
path: "/api-keys",
scope: "admin",
summary: "API Key 列表",
query: CursorQuery,
response: cursorPage(ops.ApiKey),
}),
create: ep({
method: "post",
path: "/api-keys",
scope: "admin",
summary: "新建 API Key(明文只返回一次)",
body: ops.ApiKeyCreate,
response: ops.ApiKeyCreated,
}),
update: ep({
method: "patch",
path: "/api-keys/{id}",
scope: "admin",
summary: "修改/撤销 API Key",
params: IdParam,
body: ops.ApiKeyUpdate,
response: ops.ApiKey,
}),
},
auditEvents: {
list: ep({
method: "get",
path: "/audit-events",
scope: "admin",
summary: "审计事件",
query: listQuery({
targetType: z.string().optional(),
targetId: z.string().optional(),
action: z.string().optional(),
}),
response: cursorPage(ops.AuditEvent),
}),
},
secrets: {
list: ep({
method: "get",
path: "/secrets",
scope: "admin",
summary: "凭证元数据列表",
query: CursorQuery,
response: cursorPage(ops.Secret),
}),
create: ep({
method: "post",
path: "/secrets",
scope: "admin",
summary: "新建凭证(只写)",
body: ops.SecretCreate,
response: ops.Secret,
}),
update: ep({
method: "patch",
path: "/secrets/{id}",
scope: "admin",
summary: "轮换凭证",
params: IdParam,
body: ops.SecretUpdate,
response: ops.Secret,
}),
},
// ── 整理(公开合同) ──
organization: {
list: ep({
method: "get",
path: "/organization-records",
scope: "organize.read",
summary: "整理记录列表",
query: organization.OrganizationListQuery,
response: cursorPage(organization.PublicOrganizationRecord),
}),
get: ep({
method: "get",
path: "/organization-records/{id}",
scope: "organize.read",
summary: "整理记录详情(含逐文件结果)",
params: IdParam,
response: organization.PublicOrganizationRecord,
}),
changes: ep({
method: "get",
path: "/organization-records/changes",
scope: "organize.read",
summary: "按 changeSeq 增量读取(含既有记录状态更新)",
query: organization.OrganizationChangesQuery,
response: organization.OrganizationChangesResponse,
}),
create: ep({
method: "post",
path: "/organization-records",
scope: "organize.execute",
summary: "手动发起整理",
body: organization.OrganizeRequest,
response: organization.OrganizeAccepted,
}),
},
// ── 能力 ──
capabilities: {
list: ep({
method: "get",
path: "/capabilities",
scope: "read",
summary: "能力注册中心描述",
response: z.object({ items: z.array(CapabilityDescriptor) }),
}),
invoke: ep({
method: "post",
path: "/capabilities/{capabilityId}/invoke",
scope: "read",
summary: "调用能力(实际所需 scope 见描述符;服务端按能力再次鉴权)",
params: z.object({ capabilityId: KnownCapabilityId }),
body: z.object({ input: z.unknown(), preview: z.boolean().default(false) }),
response: z.object({ output: z.unknown(), taskId: Id.nullable(), planId: Id.nullable() }),
}),
},
// ── 设置 ──
settings: {
get: ep({ method: "get", path: "/settings", scope: "read", summary: "全局设置", response: GlobalSettings }),
update: ep({
method: "patch",
path: "/settings",
scope: "admin",
summary: "修改全局设置",
body: GlobalSettingsUpdate,
response: GlobalSettings,
}),
},
} as const;
/** 用参数填充 `{name}` 路径占位,返回带 API_PREFIX 的完整路径 */
export function buildPath(path: string, params: Record<string, string> = {}): string {
const filled = path.replace(/\{(\w+)\}/g, (_, k: string) => {
const v = params[k];
if (v === undefined) throw new Error(`缺少路径参数 ${k}`);
return encodeURIComponent(v);
});
return `${API_PREFIX}${filled}`;
}
/** 扁平列出全部端点(生成 OpenAPI、权限表与测试用) */
export function listEndpoints(): EndpointDef[] {
return Object.values(Api).flatMap((group) => Object.values(group) as EndpointDef[]);
}
export type EndpointBody<E extends EndpointDef> = E["body"] extends z.ZodType ? z.input<E["body"]> : never;
export type EndpointResponse<E extends EndpointDef> = z.infer<E["response"]>;
@@ -0,0 +1,188 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
import { Segment } from "./content";
// 文件域:资产、资产-内容单元覆盖、资产位置、路径绑定(REQ §7.1、§8.2、§12.2)
// 资产与位置分离;path_binding 一旦建立不因元数据刷新改变,只有显式整理可替换(旧绑定标 superseded)。
export const AssetKind = z.enum(["video", "bluray_dir", "subtitle", "audio", "nfo", "image", "other"]);
export type AssetKind = z.infer<typeof AssetKind>;
export const AssetStatus = z.enum(["active", "replaced", "retired", "missing"]);
export type AssetStatus = z.infer<typeof AssetStatus>;
/** 交付特征(分辨率/编码/音轨/来源类型/发布组),与内容版本 contentEdition 分开(REQ §6.1) */
export const DeliveryTraits = z.object({
resolution: z.string().optional(),
videoCodec: z.string().optional(),
audioCodecs: z.array(z.string()).optional(),
hdr: z.array(z.string()).optional(),
sourceType: z.string().optional(),
releaseGroup: z.string().optional(),
subtitles: z.array(z.string()).optional(),
});
export type DeliveryTraits = z.infer<typeof DeliveryTraits>;
export const MediaAsset = EntityBase.extend({
mediaEntityId: Id.nullable(),
kind: AssetKind,
/** 附属文件(字幕/音轨)指向主视频资产 */
parentAssetId: Id.nullable(),
sizeBytes: NonNegInt.nullable(),
contentHash: z.string().nullable(),
hashAlgorithm: z.string().nullable(),
sampleDigest: z.string().nullable(),
/** ffprobe/MediaInfo 结果 */
mediaInfo: JsonObject,
delivery: DeliveryTraits,
/** 内容版本:院线版/导演剪辑版等 */
contentEdition: z.string().nullable(),
sourceReleaseId: Id.nullable(),
downloadTaskId: Id.nullable(),
status: AssetStatus,
verifiedAt: Timestamp.nullable(),
});
export type MediaAsset = z.infer<typeof MediaAsset>;
export const MediaAssetCreate = MediaAsset.omit(SERVER_MANAGED).partial({
mediaEntityId: true,
parentAssetId: true,
sizeBytes: true,
contentHash: true,
hashAlgorithm: true,
sampleDigest: true,
mediaInfo: true,
delivery: true,
contentEdition: true,
sourceReleaseId: true,
downloadTaskId: true,
status: true,
verifiedAt: true,
});
export type MediaAssetCreate = z.infer<typeof MediaAssetCreate>;
export const MediaAssetUpdate = MediaAssetCreate.partial();
export type MediaAssetUpdate = z.infer<typeof MediaAssetUpdate>;
export const CoverageKind = z.enum(["full", "partial", "segment", "unknown"]);
export type CoverageKind = z.infer<typeof CoverageKind>;
/** 一个资产覆盖哪些内容单元(E01-E03 合集、上/下分段) */
export const AssetUnitCoverage = EntityBase.extend({
mediaAssetId: Id,
contentUnitId: Id,
coverage: CoverageKind,
segment: Segment.nullable(),
evidence: JsonObject,
});
export type AssetUnitCoverage = z.infer<typeof AssetUnitCoverage>;
export const AssetUnitCoverageCreate = AssetUnitCoverage.omit(SERVER_MANAGED).partial({
coverage: true,
segment: true,
evidence: true,
});
export type AssetUnitCoverageCreate = z.infer<typeof AssetUnitCoverageCreate>;
export const AssetUnitCoverageUpdate = AssetUnitCoverageCreate.omit({
mediaAssetId: true,
contentUnitId: true,
}).partial();
export type AssetUnitCoverageUpdate = z.infer<typeof AssetUnitCoverageUpdate>;
export const LocationKind = z.enum(["library", "download_cache", "hardlink", "external"]);
export type LocationKind = z.infer<typeof LocationKind>;
/** 路径不存在时先区分根离线/权限/外部移动,再按 unavailable → suspected_missing → confirmed_missing 推进 */
export const LocationStatus = z.enum(["available", "unavailable", "suspected_missing", "confirmed_missing", "removed"]);
export type LocationStatus = z.infer<typeof LocationStatus>;
export const AssetLocation = EntityBase.extend({
mediaAssetId: Id,
locationKind: LocationKind,
libraryRootId: Id.nullable(),
downloaderInstanceId: Id.nullable(),
/** 相对库根(或下载器保存根)的路径 */
relativePath: z.string().min(1),
status: LocationStatus,
isPrimary: z.boolean(),
sizeBytes: NonNegInt.nullable(),
inode: z.number().int().nullable(),
device: z.number().int().nullable(),
mtime: Timestamp.nullable(),
sidecarOfLocationId: Id.nullable(),
lastSeenAt: Timestamp.nullable(),
lastCheckedAt: Timestamp.nullable(),
checkDetail: JsonObject,
});
export type AssetLocation = z.infer<typeof AssetLocation>;
export const AssetLocationCreate = AssetLocation.omit(SERVER_MANAGED)
.partial({
locationKind: true,
libraryRootId: true,
downloaderInstanceId: true,
status: true,
isPrimary: true,
sizeBytes: true,
inode: true,
device: true,
mtime: true,
sidecarOfLocationId: true,
lastSeenAt: true,
lastCheckedAt: true,
checkDetail: true,
})
.refine((v) => (v.locationKind ?? "library") !== "library" || !!v.libraryRootId, {
message: "library 位置必须指定 libraryRootId",
});
export type AssetLocationCreate = z.infer<typeof AssetLocationCreate>;
export const AssetLocationUpdate = AssetLocation.pick({
status: true,
isPrimary: true,
sizeBytes: true,
inode: true,
device: true,
mtime: true,
lastSeenAt: true,
lastCheckedAt: true,
checkDetail: true,
}).partial();
export type AssetLocationUpdate = z.infer<typeof AssetLocationUpdate>;
export const BindingKind = z.enum(["entity", "season"]);
export type BindingKind = z.infer<typeof BindingKind>;
export const BindingStatus = z.enum(["active", "conflict", "superseded"]);
export type BindingStatus = z.infer<typeof BindingStatus>;
export const PathBinding = EntityBase.extend({
mediaEntityId: Id,
contentUnitId: Id.nullable(),
seasonNumber: z.number().int().nonnegative().nullable(),
bindingKind: BindingKind,
libraryRootId: Id,
relativePath: z.string().min(1),
templateVersion: z.string().nullable(),
status: BindingStatus,
boundAt: Timestamp,
boundBy: z.string().nullable(),
supersededById: Id.nullable(),
note: z.string().nullable(),
});
export type PathBinding = z.infer<typeof PathBinding>;
export const PathBindingCreate = PathBinding.pick({
mediaEntityId: true,
contentUnitId: true,
seasonNumber: true,
bindingKind: true,
libraryRootId: true,
relativePath: true,
templateVersion: true,
status: true,
note: true,
})
.partial({ contentUnitId: true, seasonNumber: true, templateVersion: true, status: true, note: true })
.refine((v) => (v.bindingKind === "season") === (v.seasonNumber !== undefined && v.seasonNumber !== null), {
message: "season 绑定必须给 seasonNumber,entity 绑定不能给",
});
export type PathBindingCreate = z.infer<typeof PathBindingCreate>;
/** 绑定路径不可就地修改:只能改备注或标记冲突/被替代;换目录走整理操作生成新绑定 */
export const PathBindingUpdate = z.object({
status: BindingStatus.optional(),
supersededById: Id.nullable().optional(),
note: z.string().nullable().optional(),
});
export type PathBindingUpdate = z.infer<typeof PathBindingUpdate>;
@@ -0,0 +1,218 @@
import { z } from "zod";
import { MediaAsset } from "./assets";
import type { KnownCapabilityId } from "./capability";
import { Id, JsonObject, NonNegInt } from "./common";
import { MediaEntity, MediaKind } from "./content";
import { AcquisitionPlan, DownloadTask, Task, TaskStatus } from "./execution";
import { ExternalEntityType, ExternalIdentity } from "./identity";
import { ServerMapping } from "./ops";
import { OrganizationAction, OrganizationRecord } from "./organization";
import { AcquisitionSlot, PolicyRevision } from "./policy";
import { SourceRelease } from "./sources";
// 首批能力的输入/输出 schema;名字与 CAPABILITY_CATALOG 中的 inputSchema/outputSchema 一致。
// 写能力的输入只接受计划/对象 ID,不接受模型自拟的幂等键或任意路径。
const Limit = z.number().int().min(1).max(100).default(20);
/** 写能力的通用受理结果:返回 task/plan ID,由任务页追踪(REQ §12.3) */
export const Accepted = z.object({ taskId: Id, planId: Id.nullable() });
export type Accepted = z.infer<typeof Accepted>;
export const CatalogLookupInput = z
.object({
query: z.string().min(1).max(200).optional(),
externalId: z.object({ provider: z.string(), entityType: ExternalEntityType, externalId: z.string() }).optional(),
kind: MediaKind.optional(),
year: z.number().int().optional(),
includeRemote: z.boolean().default(false),
limit: Limit,
})
.refine((v) => !!v.query || !!v.externalId, { message: "query 与 externalId 至少给一个" });
export const CatalogLookupOutput = z.object({
local: z.array(z.object({ entity: MediaEntity, score: z.number(), matchedAlias: z.string().nullable() })),
remote: z.array(
z.object({
provider: z.string(),
entityType: ExternalEntityType,
externalId: z.string(),
title: z.string(),
year: z.number().int().nullable(),
}),
),
});
export const ReleaseSearchInput = z.object({
query: z.string().min(1).max(200).optional(),
mediaEntityId: Id.optional(),
siteIds: z.array(Id).optional(),
/** 本地快照不足时是否实时补查 */
allowLiveSearch: z.boolean().default(true),
limit: Limit,
});
export const ReleaseSearchOutput = z.object({
items: z.array(SourceRelease),
/** 各站点快照新鲜度与是否实时补查 */
coverage: z.array(
z.object({ siteId: Id, fromSnapshot: z.boolean(), live: z.boolean(), error: z.string().nullable() }),
),
});
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 IdentityResolveInput = z
.object({
observationId: Id.optional(),
sourceReleaseId: Id.optional(),
candidate: z
.object({
mediaEntityId: Id.optional(),
provider: z.string().optional(),
entityType: ExternalEntityType.optional(),
externalId: z.string().optional(),
})
.optional(),
evidenceIds: z.array(Id).default([]),
})
.refine((v) => !!v.observationId || !!v.sourceReleaseId, { message: "需要 observationId 或 sourceReleaseId" });
export const IdentityResolveOutput = z.object({
status: z.enum(["resolved", "proposed", "ambiguous", "unidentifiable"]),
mediaEntityId: Id.nullable(),
identities: z.array(ExternalIdentity),
decisionId: Id.nullable(),
});
export const DownloadPlanInput = z.object({ slotId: Id, sourceReleaseId: Id });
export const DownloadPlanOutput = z.object({
plan: AcquisitionPlan.nullable(),
slot: AcquisitionSlot,
/** 硬条件不满足或预算/阈值阻止时的原因 */
rejected: z.array(z.object({ code: z.string(), message: z.string() })),
requiresApproval: z.boolean(),
});
export const DownloadSubmitInput = z.object({ planId: Id, approvalId: Id.optional() });
export const DownloadSubmitOutput = Accepted.extend({ downloadTask: DownloadTask.nullable() });
export const OrganizePlanInput = z
.object({
downloadTaskId: Id.optional(),
mediaAssetIds: z.array(Id).optional(),
action: OrganizationAction.default("copy"),
targetLibraryRootId: Id.optional(),
})
.refine((v) => !!v.downloadTaskId || !!v.mediaAssetIds?.length, { message: "需要 downloadTaskId 或 mediaAssetIds" });
export const OrganizePlanOutput = z.object({
planId: Id,
payloadHash: z.string(),
files: z.array(
z.object({
sourcePath: z.string(),
targetPath: z.string().nullable(),
action: OrganizationAction,
skipReason: z.string().nullable(),
}),
),
conflicts: z.array(z.object({ code: z.string(), message: z.string(), path: z.string().nullable() })),
requiresApproval: z.boolean(),
});
export const OrganizeExecuteInput = z.object({ planId: Id, payloadHash: z.string().min(1), approvalId: Id.optional() });
export const OrganizeExecuteOutput = Accepted.extend({ record: OrganizationRecord.nullable() });
export const TaskInspectInput = z.object({ taskId: Id });
export const TaskInspectOutput = z.object({
task: Task,
children: z.array(Task),
timeline: z.array(z.object({ at: z.string(), type: z.string(), summary: z.string(), data: JsonObject.optional() })),
});
export const TaskRetryInput = z.object({ taskId: Id, reason: z.string().max(500).optional() });
export const TaskRetryOutput = z.object({ taskId: Id, status: TaskStatus });
export const TaskCancelInput = z.object({ taskId: Id, reason: z.string().min(1).max(500) });
export const TaskCancelOutput = z.object({ taskId: Id, status: TaskStatus, cancelRequested: z.boolean() });
export const PolicyPreviewInput = z.object({
policyRevisionId: Id,
sampleLimit: z.number().int().min(1).max(1000).default(200),
});
export const PolicyPreviewOutput = z.object({
newlyMatched: NonNegInt,
newlyExcluded: NonNegInt,
samples: z.array(z.object({ sourceReleaseId: Id, before: z.string(), after: z.string(), reason: z.string() })),
});
export const PolicyApplyInput = z.object({
policyRevisionId: Id,
expectedRevision: z.number().int().min(1),
approvalId: Id.optional(),
});
export const PolicyApplyOutput = z.object({ policy: PolicyRevision });
export const InventoryQueryInput = z.object({
mediaEntityId: Id.optional(),
query: z.string().max(200).optional(),
includeServerInventory: z.boolean().default(false),
limit: Limit,
});
export const InventoryQueryOutput = z.object({
items: z.array(
z.object({
entity: MediaEntity,
assets: z.array(MediaAsset),
slots: z.array(AcquisitionSlot),
identities: z.array(ExternalIdentity.pick({ provider: true, entityType: true, externalId: true, status: true })),
/** 外部库存证据;"未知 ≠ 缺失" */
server: z.array(ServerMapping),
}),
),
});
/** 能力 ID → 输入/输出 schema(server 注册处理器、agent 生成工具定义时共用) */
export const CAPABILITY_IO = {
"catalog.lookup": { input: CatalogLookupInput, output: CatalogLookupOutput },
"release.search": { input: ReleaseSearchInput, output: ReleaseSearchOutput },
"release.inspect": { input: ReleaseInspectInput, output: ReleaseInspectOutput },
"identity.resolve": { input: IdentityResolveInput, output: IdentityResolveOutput },
"download.plan": { input: DownloadPlanInput, output: DownloadPlanOutput },
"download.submit": { input: DownloadSubmitInput, output: DownloadSubmitOutput },
"organize.plan": { input: OrganizePlanInput, output: OrganizePlanOutput },
"organize.execute": { input: OrganizeExecuteInput, output: OrganizeExecuteOutput },
"task.inspect": { input: TaskInspectInput, output: TaskInspectOutput },
"task.retry": { input: TaskRetryInput, output: TaskRetryOutput },
"task.cancel": { input: TaskCancelInput, output: TaskCancelOutput },
"policy.preview": { input: PolicyPreviewInput, output: PolicyPreviewOutput },
"policy.apply": { input: PolicyApplyInput, output: PolicyApplyOutput },
"inventory.query": { input: InventoryQueryInput, output: InventoryQueryOutput },
} as const satisfies Record<KnownCapabilityId, { input: z.ZodType; output: z.ZodType }>;
export type CatalogLookupInput = z.infer<typeof CatalogLookupInput>;
export type CatalogLookupOutput = z.infer<typeof CatalogLookupOutput>;
export type ReleaseSearchInput = z.infer<typeof ReleaseSearchInput>;
export type ReleaseSearchOutput = z.infer<typeof ReleaseSearchOutput>;
export type ReleaseInspectInput = z.infer<typeof ReleaseInspectInput>;
export type ReleaseInspectOutput = z.infer<typeof ReleaseInspectOutput>;
export type IdentityResolveInput = z.infer<typeof IdentityResolveInput>;
export type IdentityResolveOutput = z.infer<typeof IdentityResolveOutput>;
export type DownloadPlanInput = z.infer<typeof DownloadPlanInput>;
export type DownloadPlanOutput = z.infer<typeof DownloadPlanOutput>;
export type DownloadSubmitInput = z.infer<typeof DownloadSubmitInput>;
export type DownloadSubmitOutput = z.infer<typeof DownloadSubmitOutput>;
export type OrganizePlanInput = z.infer<typeof OrganizePlanInput>;
export type OrganizePlanOutput = z.infer<typeof OrganizePlanOutput>;
export type OrganizeExecuteInput = z.infer<typeof OrganizeExecuteInput>;
export type OrganizeExecuteOutput = z.infer<typeof OrganizeExecuteOutput>;
export type TaskInspectInput = z.infer<typeof TaskInspectInput>;
export type TaskInspectOutput = z.infer<typeof TaskInspectOutput>;
export type TaskRetryInput = z.infer<typeof TaskRetryInput>;
export type TaskRetryOutput = z.infer<typeof TaskRetryOutput>;
export type TaskCancelInput = z.infer<typeof TaskCancelInput>;
export type TaskCancelOutput = z.infer<typeof TaskCancelOutput>;
export type PolicyPreviewInput = z.infer<typeof PolicyPreviewInput>;
export type PolicyPreviewOutput = z.infer<typeof PolicyPreviewOutput>;
export type PolicyApplyInput = z.infer<typeof PolicyApplyInput>;
export type PolicyApplyOutput = z.infer<typeof PolicyApplyOutput>;
export type InventoryQueryInput = z.infer<typeof InventoryQueryInput>;
export type InventoryQueryOutput = z.infer<typeof InventoryQueryOutput>;
@@ -0,0 +1,280 @@
import { z } from "zod";
import { ApiScope } from "./common";
/**
* 能力调用上下文(REQ §10.3)。产品自有接口,不是 Pi SDK 原生 API。
* signal 由任务取消传播而来。
*/
export interface CapabilityContext {
actorId: string;
taskId: string;
scopeIds: string[];
signal: AbortSignal;
}
/** 变更计划:由业务端生成与保存,审批绑定 id + payloadHash + expectedRevision */
export const MutationPlan = z.object({
id: z.string(),
operation: z.string(),
targetIds: z.array(z.string()),
expectedRevision: z.number().int().nonnegative(),
evidenceIds: z.array(z.string()),
payloadHash: z.string(),
requiresApproval: z.boolean(),
});
export type MutationPlan = z.infer<typeof MutationPlan>;
/** 能力风险级别 */
export const CapabilityRisk = z.enum(["read", "preview", "write", "destructive"]);
export type CapabilityRisk = z.infer<typeof CapabilityRisk>;
/** 能力 ID:`域.动作`,如 download.submit */
export const CapabilityId = z.string().regex(/^[a-z][a-z0-9]*\.[a-z][a-z0-9_]*$/);
export type CapabilityId = z.infer<typeof CapabilityId>;
/** 能力定义(注册中心元数据;处理器在 server 侧挂载) */
export interface CapabilityDefinition<I extends z.ZodType = z.ZodType, O extends z.ZodType = z.ZodType> {
id: CapabilityId;
version: number;
description: string;
input: I;
output: O;
scopes: string[];
risk: CapabilityRisk;
idempotent: boolean;
supportsPreview: boolean;
}
// ── 能力描述符与首批能力(任务书 §3.1-9;REQ §10.3) ──
/** 风险级别:low 只读;medium 预览/可撤回写入;high 触发下载、文件变更或策略发布 */
export const RiskLevel = z.enum(["low", "medium", "high"]);
export type RiskLevel = z.infer<typeof RiskLevel>;
/** 接口类别(REQ §12.3):read / preview / propose / approve / execute */
export const CapabilityKind = z.enum(["read", "preview", "propose", "approve", "execute"]);
export type CapabilityKind = z.infer<typeof CapabilityKind>;
/** 首批能力 ID */
export const CAPABILITY_IDS = [
"catalog.lookup",
"release.search",
"release.inspect",
"identity.resolve",
"download.plan",
"download.submit",
"organize.plan",
"organize.execute",
"task.inspect",
"task.retry",
"task.cancel",
"policy.preview",
"policy.apply",
"inventory.query",
] as const;
export const KnownCapabilityId = z.enum(CAPABILITY_IDS);
export type KnownCapabilityId = z.infer<typeof KnownCapabilityId>;
/** schema 引用:指向 contracts 中导出的 schema 名(如 "ReleaseSearchInput"),便于序列化给 UI/MCP 列表 */
export const SchemaRef = z.string().regex(/^[A-Z][A-Za-z0-9]*$/);
/** 能力注册中心的可序列化描述(处理器在 server 侧挂载;模型只能看到白名单内的描述) */
export const CapabilityDescriptor = z.object({
id: CapabilityId,
version: z.number().int().min(1),
title: z.string().min(1),
description: z.string().min(1),
kind: CapabilityKind,
inputSchema: SchemaRef,
outputSchema: SchemaRef,
/** 调用所需 API scope */
scope: ApiScope,
riskLevel: RiskLevel,
idempotent: z.boolean(),
supportsPreview: z.boolean(),
});
export type CapabilityDescriptor = z.infer<typeof CapabilityDescriptor>;
/** 首批能力的描述(inputSchema/outputSchema 名对应 capabilities-io.ts 中的导出) */
export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor> = {
"catalog.lookup": d(
"catalog.lookup",
"查作品",
"按标题/别名/外部 ID 查本地作品与元数据候选",
"read",
"CatalogLookup",
"read",
"low",
true,
false,
),
"release.search": d(
"release.search",
"搜资源",
"先查本地资源索引与站点快照,不足时对已开启站点实时补查",
"read",
"ReleaseSearch",
"read",
"low",
true,
false,
),
"release.inspect": d(
"release.inspect",
"看资源详情",
"读取站点资源详情、文件清单、促销与解析结果",
"read",
"ReleaseInspect",
"read",
"low",
true,
false,
),
"identity.resolve": d(
"identity.resolve",
"识别身份",
"为观察/资源提出身份映射;高证据自动接受,歧义进入待处理",
"propose",
"IdentityResolve",
"organize.execute",
"medium",
true,
true,
),
"download.plan": d(
"download.plan",
"生成下载计划",
"评估候选并生成带 Slot/预算预留的下载计划(不提交)",
"preview",
"DownloadPlan",
"organize.execute",
"medium",
true,
true,
),
"download.submit": d(
"download.submit",
"提交下载",
"按已保存的计划提交到下载器;校验授权与硬阈值",
"execute",
"DownloadSubmit",
"organize.execute",
"high",
true,
false,
),
"organize.plan": d(
"organize.plan",
"生成整理计划",
"冻结源/目标路径、操作类型与前置条件",
"preview",
"OrganizePlan",
"organize.read",
"low",
true,
true,
),
"organize.execute": d(
"organize.execute",
"执行整理",
"执行已保存的整理计划(Copy/Move),生成整理记录",
"execute",
"OrganizeExecute",
"organize.execute",
"high",
true,
false,
),
"task.inspect": d(
"task.inspect",
"查看任务",
"读取任务状态、业务时间线与错误",
"read",
"TaskInspect",
"read",
"low",
true,
false,
),
"task.retry": d(
"task.retry",
"重试任务",
"对失败/死信任务重新排队;副作用经操作账本去重",
"execute",
"TaskRetry",
"organize.execute",
"medium",
true,
false,
),
"task.cancel": d(
"task.cancel",
"取消任务",
"停止未来步骤,不撤销已完成的外部动作",
"execute",
"TaskCancel",
"organize.execute",
"medium",
true,
false,
),
"policy.preview": d(
"policy.preview",
"预览策略",
"用历史数据回放策略草案,展示新增命中与误排除",
"preview",
"PolicyPreview",
"read",
"low",
true,
true,
),
"policy.apply": d(
"policy.apply",
"发布策略",
"发布策略版本(需授权);不直接执行 JavaScript",
"execute",
"PolicyApply",
"admin",
"high",
true,
true,
),
"inventory.query": d(
"inventory.query",
"查库存",
"查询本地资产、Slot 满足度与外部库存证据",
"read",
"InventoryQuery",
"read",
"low",
true,
false,
),
};
function d(
id: KnownCapabilityId,
title: string,
description: string,
kind: CapabilityKind,
schemaBase: string,
scope: ApiScope,
riskLevel: RiskLevel,
idempotent: boolean,
supportsPreview: boolean,
): CapabilityDescriptor {
return {
id,
version: 1,
title,
description,
kind,
inputSchema: `${schemaBase}Input`,
outputSchema: `${schemaBase}Output`,
scope,
riskLevel,
idempotent,
supportsPreview,
};
}
@@ -0,0 +1,122 @@
import { z } from "zod";
/** 本地实体 ID 一律 UUID */
export const Id = z.uuid();
export type Id = z.infer<typeof Id>;
/** ISO 8601 时间串 */
export const Timestamp = z.iso.datetime({ offset: true });
export type Timestamp = z.infer<typeof Timestamp>;
/** 游标分页请求:cursor 为不透明字符串,由服务端编码 */
export const CursorQuery = z.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
export type CursorQuery = z.infer<typeof CursorQuery>;
/** 游标分页结果:nextCursor 为 null 表示已到末尾 */
export const cursorPage = <T extends z.ZodType>(item: T) =>
z.object({
items: z.array(item),
nextCursor: z.string().nullable(),
});
export type CursorPage<T> = { items: T[]; nextCursor: string | null };
/** 错误码表:新增错误码只在这里加 */
export const ApiErrorCode = z.enum([
"bad_request",
"validation_failed",
"unauthorized",
"forbidden",
"not_found",
"conflict",
"precondition_failed",
"rate_limited",
"automation_disabled",
"upstream_unavailable",
"internal",
]);
export type ApiErrorCode = z.infer<typeof ApiErrorCode>;
/** 错误码对应的 HTTP 状态 */
export const API_ERROR_STATUS = {
bad_request: 400,
validation_failed: 422,
unauthorized: 401,
forbidden: 403,
not_found: 404,
conflict: 409,
precondition_failed: 412,
rate_limited: 429,
automation_disabled: 423,
upstream_unavailable: 502,
internal: 500,
} as const satisfies Record<ApiErrorCode, number>;
export const ApiError = z.object({
code: ApiErrorCode,
message: z.string(),
});
export type ApiError = z.infer<typeof ApiError>;
/** 统一响应信封 */
export const apiResponse = <T extends z.ZodType>(data: T) =>
z.object({
ok: z.boolean(),
data: data.optional(),
error: ApiError.optional(),
});
export type ApiResponse<T> =
| { ok: true; data: T; error?: undefined }
| { ok: false; data?: undefined; error: ApiError };
/** 没有 data 的失败响应 schema(OpenAPI 错误分支用) */
export const ApiErrorResponse = z.object({
ok: z.literal(false),
error: ApiError,
});
/** API Key 权限范围 */
export const ApiScope = z.enum(["read", "organize.read", "organize.execute", "admin"]);
export type ApiScope = z.infer<typeof ApiScope>;
/** 健康检查 */
export const HealthStatus = z.object({
status: z.enum(["ok", "degraded"]),
version: z.string(),
role: z.enum(["api", "worker", "all"]),
uptimeSec: z.number(),
db: z.enum(["ok", "down", "unknown"]),
time: Timestamp,
});
export type HealthStatus = z.infer<typeof HealthStatus>;
/** 日期(无时区),如 air_date:YYYY-MM-DD */
export const DateOnly = z.iso.date();
export type DateOnly = z.infer<typeof DateOnly>;
/** JSONB 对象列(证据、规则参数、原文快照等)的 API 表示 */
export const JsonObject = z.record(z.string(), z.unknown());
export type JsonObject = z.infer<typeof JsonObject>;
/** 字节数、计数等非负整数(DB 为 bigint,客户端按 number 解析,上限 2^53) */
export const NonNegInt = z.number().int().nonnegative();
/** 操作者类型:与 DB 中 actor_type 列一致 */
export const ActorType = z.enum(["user", "agent", "system", "api_key"]);
export type ActorType = z.infer<typeof ActorType>;
/** 实体行的公共字段 */
export const EntityBase = z.object({
id: Id,
createdAt: Timestamp,
updatedAt: Timestamp,
});
/** 所有实体 Create 输入都要剔除的服务端字段 */
export const SERVER_MANAGED = { id: true, createdAt: true, updatedAt: true } as const;
/** 路径参数 `{id}` */
export const IdParam = z.object({ id: Id });
export type IdParam = z.infer<typeof IdParam>;
@@ -0,0 +1,129 @@
import { z } from "zod";
import { DateOnly, EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common";
// 内容域:本地作品、内容单元、别名(REQ §3.1、§5.1、§5.4、§7.5)
// 本地 UUID 是主身份;季/集编号、外部 ID 都可修订,不是唯一身份。
export const MediaKind = z.enum(["movie", "series", "variety", "collection", "other"]);
export type MediaKind = z.infer<typeof MediaKind>;
export const MediaEntityStatus = z.enum(["provisional", "active", "merged", "archived"]);
export type MediaEntityStatus = z.infer<typeof MediaEntityStatus>;
export const MediaEntity = EntityBase.extend({
kind: MediaKind,
title: z.string().min(1),
originalTitle: z.string().nullable(),
year: z.number().int().min(1870).max(2200).nullable(),
status: MediaEntityStatus,
/** status=merged 时指向合并后的实体(旧 ID 跳转) */
mergedIntoId: Id.nullable(),
/** 最终展示字段(每个字段带来源/语言/时间) */
metadata: JsonObject,
/** 人工锁定的字段名;刷新不得覆盖 */
fieldLocks: z.array(z.string()),
lastMetadataRefreshAt: Timestamp.nullable(),
});
export type MediaEntity = z.infer<typeof MediaEntity>;
export const MediaEntityCreate = MediaEntity.pick({
kind: true,
title: true,
originalTitle: true,
year: true,
status: true,
metadata: true,
fieldLocks: true,
}).partial({ originalTitle: true, year: true, status: true, metadata: true, fieldLocks: true });
export type MediaEntityCreate = z.infer<typeof MediaEntityCreate>;
/** 合并通过专门动作完成,Update 不接受 status=merged */
export const MediaEntityUpdate = MediaEntityCreate.partial().extend({
status: MediaEntityStatus.exclude(["merged"]).optional(),
});
export type MediaEntityUpdate = z.infer<typeof MediaEntityUpdate>;
export const ContentUnitKind = z.enum(["season", "episode", "special", "part"]);
export type ContentUnitKind = z.infer<typeof ContentUnitKind>;
/** 综艺内容类型(REQ §5.1) */
export const ContentType = z.enum([
"main",
"extra",
"behind_the_scenes",
"performance_only",
"preview",
"reaction",
"unknown",
]);
export type ContentType = z.infer<typeof ContentType>;
export const Segment = z.enum(["full", "upper", "middle", "lower", "unknown"]);
export type Segment = z.infer<typeof Segment>;
export const ContentUnitStatus = z.enum(["provisional", "confirmed", "retired"]);
export type ContentUnitStatus = z.infer<typeof ContentUnitStatus>;
export const ContentUnit = EntityBase.extend({
mediaEntityId: Id,
parentUnitId: Id.nullable(),
kind: ContentUnitKind,
contentType: ContentType,
segment: Segment,
seasonNumber: z.number().int().nonnegative().nullable(),
episodeNumber: z.number().int().nonnegative().nullable(),
/** 来源期号原文,如 "第十期"、"20260927" */
sourceIssue: z.string().nullable(),
airDate: DateOnly.nullable(),
displayOrder: z.number().nullable(),
title: z.string().nullable(),
status: ContentUnitStatus,
/** 人工确认的编号锁定;刷新不得改编号 */
numberingLocked: z.boolean(),
metadata: JsonObject,
fieldLocks: z.array(z.string()),
});
export type ContentUnit = z.infer<typeof ContentUnit>;
export const ContentUnitCreate = ContentUnit.omit(SERVER_MANAGED).partial({
parentUnitId: true,
contentType: true,
segment: true,
seasonNumber: true,
episodeNumber: true,
sourceIssue: true,
airDate: true,
displayOrder: true,
title: true,
status: true,
numberingLocked: true,
metadata: true,
fieldLocks: true,
});
export type ContentUnitCreate = z.infer<typeof ContentUnitCreate>;
export const ContentUnitUpdate = ContentUnitCreate.omit({ mediaEntityId: true }).partial();
export type ContentUnitUpdate = z.infer<typeof ContentUnitUpdate>;
export const AliasKind = z.enum(["original", "translated", "short", "site_title", "romanized", "other"]);
export type AliasKind = z.infer<typeof AliasKind>;
export const AliasSource = z.enum(["tmdb", "user", "site", "agent", "nfo", "import"]);
export type AliasSource = z.infer<typeof AliasSource>;
export const TitleAlias = EntityBase.extend({
mediaEntityId: Id,
contentUnitId: Id.nullable(),
alias: z.string().min(1),
/** 归一化后的检索键(小写、去标点、全半角统一),建 trgm 索引 */
normalized: z.string().min(1),
language: z.string().nullable(),
kind: AliasKind,
source: AliasSource,
locked: z.boolean(),
});
export type TitleAlias = z.infer<typeof TitleAlias>;
/** normalized 由服务端计算,不接受客户端传入 */
export const TitleAliasCreate = TitleAlias.pick({
mediaEntityId: true,
contentUnitId: true,
alias: true,
language: true,
kind: true,
source: true,
locked: true,
}).partial({ contentUnitId: true, language: true, kind: true, locked: true });
export type TitleAliasCreate = z.infer<typeof TitleAliasCreate>;
export const TitleAliasUpdate = TitleAliasCreate.omit({ mediaEntityId: true }).partial();
export type TitleAliasUpdate = z.infer<typeof TitleAliasUpdate>;
@@ -0,0 +1,192 @@
import { z } from "zod";
import { AgentRunStatus, DecisionKind, DecisionStatus, Severity } from "./agent";
import { Id, NonNegInt, Timestamp } from "./common";
import { DownloadStatus, PauseReason, TaskKind, TaskStatus } from "./execution";
import { OrganizationStatus } from "./organization";
import { FeedHealth } from "./sources";
/** SSE 事件信封:seq 在进程内单调递增,客户端断线后用 ?after=<seq> 补读 */
export const EventEnvelope = z.object({
seq: z.number().int().nonnegative(),
type: z.string().min(1),
at: Timestamp,
payload: z.unknown(),
});
export type EventEnvelope<P = unknown> = {
seq: number;
type: string;
at: string;
payload: P;
};
/** 已知事件类型:`域.动作`。新增事件先在这里加类型与 payload schema */
export const EventType = {
heartbeat: "system.heartbeat",
resync: "system.resync",
taskCreated: "task.created",
taskStatusChanged: "task.status_changed",
downloadSubmitted: "download.submitted",
downloadProgress: "download.progress",
downloadStatusChanged: "download.status_changed",
downloadThreshold: "download.threshold",
organizationChanged: "organization.changed",
agentRunStatus: "agent.run_status",
agentTextDelta: "agent.text_delta",
agentToolCall: "agent.tool_call",
feedFetched: "feed.fetched",
feedHealthChanged: "feed.health_changed",
decisionCreated: "decision.created",
decisionUpdated: "decision.updated",
} as const;
export type EventType = (typeof EventType)[keyof typeof EventType];
/** 心跳 payload */
export const HeartbeatPayload = z.object({
role: z.string(),
});
/** 服务端环形缓冲不足以补读时发出,客户端应整页刷新 */
export const ResyncPayload = z.object({ reason: z.string() });
export const TaskCreatedPayload = z.object({
taskId: Id,
kind: TaskKind,
subjectType: z.string().nullable(),
subjectId: Id.nullable(),
});
export const TaskStatusChangedPayload = z.object({
taskId: Id,
kind: TaskKind,
from: TaskStatus.nullable(),
to: TaskStatus,
error: z.string().nullable(),
});
export const DownloadSubmittedPayload = z.object({
downloadTaskId: Id,
instanceId: Id,
torrentHash: z.string(),
planId: Id.nullable(),
name: z.string(),
});
export const DownloadProgressPayload = z.object({
downloadTaskId: Id,
progress: z.number().min(0).max(1),
downloadedBytes: NonNegInt,
sizeBytes: NonNegInt.nullable(),
});
export const DownloadStatusChangedPayload = z.object({
downloadTaskId: Id,
from: DownloadStatus.nullable(),
to: DownloadStatus,
pauseReason: PauseReason.nullable(),
});
/** 缓存卷越过硬阈值(tripped)或回到恢复水位(recovered) */
export const DownloadThresholdPayload = z.object({
volumeKey: z.string(),
state: z.enum(["tripped", "recovered", "unknown"]),
usedPercent: z.number().min(0).max(100).nullable(),
pausedTaskIds: z.array(Id),
});
/** 整理记录新增或状态变化;changeSeq 与公开 API 的游标一致 */
export const OrganizationChangedPayload = z.object({
recordId: Id,
status: OrganizationStatus,
changeSeq: z.string().regex(/^\d+$/),
mediaEntityId: Id.nullable(),
});
export const AgentRunStatusPayload = z.object({
runId: Id,
status: AgentRunStatus,
summary: z.string().nullable(),
});
/** Agent 文本增量:runSeq 为该 run 内序号,配合 agent_run.last_event_seq 断点续读 */
export const AgentTextDeltaPayload = z.object({
runId: Id,
runSeq: NonNegInt,
delta: z.string(),
});
export const AgentToolCallPayload = z.object({
runId: Id,
runSeq: NonNegInt,
capabilityId: z.string(),
phase: z.enum(["started", "succeeded", "failed", "denied"]),
/** 已脱敏的摘要,不含凭据/私有 URL */
summary: z.string(),
});
export const FeedFetchedPayload = z.object({
feedId: Id,
newItems: NonNegInt,
updatedItems: NonNegInt,
notModified: z.boolean(),
});
export const FeedHealthChangedPayload = z.object({
feedId: Id,
from: FeedHealth.nullable(),
to: FeedHealth,
reason: z.string().nullable(),
});
export const DecisionCreatedPayload = z.object({
decisionId: Id,
kind: DecisionKind,
severity: Severity,
title: z.string(),
});
export const DecisionUpdatedPayload = z.object({
decisionId: Id,
status: DecisionStatus,
});
/** 事件类型 → payload schema */
export const EVENT_PAYLOADS = {
"system.heartbeat": HeartbeatPayload,
"system.resync": ResyncPayload,
"task.created": TaskCreatedPayload,
"task.status_changed": TaskStatusChangedPayload,
"download.submitted": DownloadSubmittedPayload,
"download.progress": DownloadProgressPayload,
"download.status_changed": DownloadStatusChangedPayload,
"download.threshold": DownloadThresholdPayload,
"organization.changed": OrganizationChangedPayload,
"agent.run_status": AgentRunStatusPayload,
"agent.text_delta": AgentTextDeltaPayload,
"agent.tool_call": AgentToolCallPayload,
"feed.fetched": FeedFetchedPayload,
"feed.health_changed": FeedHealthChangedPayload,
"decision.created": DecisionCreatedPayload,
"decision.updated": DecisionUpdatedPayload,
} as const satisfies Record<EventType, z.ZodType>;
export type EventPayload<T extends EventType> = z.infer<(typeof EVENT_PAYLOADS)[T]>;
/** 已知事件的判别联合:`{ type, payload }` */
export type TypedEvent = { [T in EventType]: { type: T; payload: EventPayload<T> } }[EventType];
/** 已知事件信封的 schema(判别联合) */
export const TypedEventEnvelope = z.discriminatedUnion(
"type",
Object.entries(EVENT_PAYLOADS).map(([type, payload]) =>
z.object({ seq: z.number().int().nonnegative(), at: Timestamp, type: z.literal(type), payload }),
) as unknown as [z.ZodObject, ...z.ZodObject[]],
);
export function isEventType(type: string): type is EventType {
return Object.hasOwn(EVENT_PAYLOADS, type);
}
/** 校验一个事件 payload;不合法返回 null */
export function parseEventPayload<T extends EventType>(type: T, payload: unknown): EventPayload<T> | null {
const schema: z.ZodType = EVENT_PAYLOADS[type];
const r = schema.safeParse(payload);
return r.success ? (r.data as EventPayload<T>) : null;
}
@@ -0,0 +1,292 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
import { AcquisitionPriority } from "./policy";
// 执行域:业务任务、下载计划、下载任务、操作账本(REQ §8.4、§11;ADR-002)
/** 持久任务类型(任务书 §3.1-8)+ Agent 有界任务 */
export const TaskKind = z.enum([
"ingestFeed",
"resolveObservation",
"evaluateAcquisition",
"submitDownload",
"verifyAndImport",
"refreshMetadata",
"reconcileLibrary",
"cleanupDownloadCache",
"resolveDecision",
"agentRun",
]);
export type TaskKind = z.infer<typeof TaskKind>;
export const TaskStatus = z.enum(["pending", "running", "waiting", "succeeded", "failed", "cancelled", "dead"]);
export type TaskStatus = z.infer<typeof TaskStatus>;
export const Task = EntityBase.extend({
kind: TaskKind,
handlerVersion: z.number().int().min(1),
idempotencyKey: z.string().min(1),
status: TaskStatus,
/** 数值越小越先执行 */
priority: z.number().int(),
input: JsonObject,
output: JsonObject.nullable(),
subjectType: z.string().nullable(),
subjectId: Id.nullable(),
parentTaskId: Id.nullable(),
engineRunId: z.string().nullable(),
attempts: NonNegInt,
maxAttempts: z.number().int().min(1),
nextRunAt: Timestamp,
waitReason: z.string().nullable(),
cancelRequestedAt: Timestamp.nullable(),
cancelReason: z.string().nullable(),
lastError: JsonObject.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
triggeredBy: z.string().nullable(),
});
export type Task = z.infer<typeof Task>;
/** 内部创建输入(幂等键由服务端按语义生成,不能由模型挑选) */
export const TaskCreate = Task.pick({
kind: true,
handlerVersion: true,
idempotencyKey: true,
priority: true,
input: true,
subjectType: true,
subjectId: true,
parentTaskId: true,
maxAttempts: true,
nextRunAt: true,
triggeredBy: true,
}).partial({
handlerVersion: true,
priority: true,
input: true,
subjectType: true,
subjectId: true,
parentTaskId: true,
maxAttempts: true,
nextRunAt: true,
triggeredBy: true,
});
export type TaskCreate = z.infer<typeof TaskCreate>;
export const TaskUpdate = Task.pick({
status: true,
output: true,
engineRunId: true,
attempts: true,
nextRunAt: true,
waitReason: true,
lastError: true,
startedAt: true,
finishedAt: true,
}).partial();
export type TaskUpdate = z.infer<typeof TaskUpdate>;
/** 取消 = 停止未来步骤,不撤销已完成外部动作(REQ §11.4) */
export const TaskCancelRequest = z.object({ reason: z.string().min(1).max(500) });
export type TaskCancelRequest = z.infer<typeof TaskCancelRequest>;
export const PlanPurpose = z.enum(["fill", "upgrade", "replace_source"]);
export type PlanPurpose = z.infer<typeof PlanPurpose>;
export const PlanStatus = z.enum(["draft", "reserved", "submitted", "completed", "cancelled", "failed", "expired"]);
export type PlanStatus = z.infer<typeof PlanStatus>;
/** 下载计划:与 Slot 预留、预算预留同事务创建;一个 Slot 同时只能有一个 reserved/submitted 计划 */
export const AcquisitionPlan = EntityBase.extend({
slotId: Id,
sourceReleaseId: Id,
taskId: Id.nullable(),
purpose: PlanPurpose,
status: PlanStatus,
priority: AcquisitionPriority,
/** evaluateAcquisition 的确定性评估结果与拒绝/接受理由 */
evaluation: JsonObject,
reservedBytes: NonNegInt,
budgetReservation: JsonObject,
payloadHash: z.string().min(1),
expectedRevision: z.number().int().min(1),
idempotencyKey: z.string().min(1),
expiresAt: Timestamp.nullable(),
statusReason: z.string().nullable(),
});
export type AcquisitionPlan = z.infer<typeof AcquisitionPlan>;
export const AcquisitionPlanCreate = AcquisitionPlan.omit({ ...SERVER_MANAGED, status: true }).partial({
taskId: true,
purpose: true,
priority: true,
evaluation: true,
reservedBytes: true,
budgetReservation: true,
expectedRevision: true,
expiresAt: true,
statusReason: true,
});
export type AcquisitionPlanCreate = z.infer<typeof AcquisitionPlanCreate>;
export const AcquisitionPlanUpdate = AcquisitionPlan.pick({ status: true, taskId: true, statusReason: true }).partial();
export type AcquisitionPlanUpdate = z.infer<typeof AcquisitionPlanUpdate>;
export const DownloadOrigin = z.enum(["submitted", "adopted"]);
export type DownloadOrigin = z.infer<typeof DownloadOrigin>;
export const DownloadStatus = z.enum([
"queued",
"submitting",
"downloading",
"paused",
"stalled",
"completed",
"importing",
"imported",
"failed",
"removed",
"missing",
]);
export type DownloadStatus = z.infer<typeof DownloadStatus>;
/** 暂停原因:阈值恢复时只恢复 threshold 暂停项,不恢复用户手动暂停(REQ §8.4.1) */
export const PauseReason = z.enum(["user", "threshold", "policy", "agent"]);
export type PauseReason = z.infer<typeof PauseReason>;
export const TorrentHash = z.string().regex(/^[0-9a-f]{40}$|^[0-9a-f]{64}$/);
export const DownloadTask = EntityBase.extend({
instanceId: Id,
/** 该下载器实例内的 torrent 标识(v1 hash 或 v2 hash,小写十六进制) */
torrentHash: TorrentHash,
infohashV1: z.string().nullable(),
infohashV2: z.string().nullable(),
acquisitionPlanId: Id.nullable(),
sourceReleaseId: Id.nullable(),
name: z.string(),
savePath: z.string().nullable(),
contentPath: z.string().nullable(),
category: z.string().nullable(),
tags: z.array(z.string()),
/** 是否在管理标签范围内;标签移除后暂停后续自动动作 */
managed: z.boolean(),
origin: DownloadOrigin,
status: DownloadStatus,
pauseReason: PauseReason.nullable(),
rawState: z.string().nullable(),
progress: z.number().min(0).max(1),
sizeBytes: NonNegInt.nullable(),
downloadedBytes: NonNegInt,
remainingBytes: NonNegInt.nullable(),
ratio: z.number().nullable(),
seedingTimeSec: NonNegInt.nullable(),
completedAt: Timestamp.nullable(),
lastProgressAt: Timestamp.nullable(),
/** 无进展起点:只记录并汇入待处理,不自动换源(REQ §8.4.2) */
noProgressSince: Timestamp.nullable(),
lastSyncedAt: Timestamp.nullable(),
nextCheckAt: Timestamp.nullable(),
removedAt: Timestamp.nullable(),
fileList: z.array(z.object({ path: z.string(), size: NonNegInt, progress: z.number().optional() })).nullable(),
lastError: JsonObject.nullable(),
});
export type DownloadTask = z.infer<typeof DownloadTask>;
export const DownloadTaskCreate = DownloadTask.pick({
instanceId: true,
torrentHash: true,
infohashV1: true,
infohashV2: true,
acquisitionPlanId: true,
sourceReleaseId: true,
name: true,
savePath: true,
category: true,
tags: true,
managed: true,
origin: true,
}).partial({
infohashV1: true,
infohashV2: true,
acquisitionPlanId: true,
sourceReleaseId: true,
savePath: true,
category: true,
tags: true,
managed: true,
origin: true,
});
export type DownloadTaskCreate = z.infer<typeof DownloadTaskCreate>;
export const DownloadTaskUpdate = DownloadTask.omit({
...SERVER_MANAGED,
instanceId: true,
torrentHash: true,
}).partial();
export type DownloadTaskUpdate = z.infer<typeof DownloadTaskUpdate>;
export const OperationPhase = z.enum(["decision", "prepare", "transfer", "verify", "commit", "cleanup", "compensate"]);
export type OperationPhase = z.infer<typeof OperationPhase>;
export const OperationKind = z.enum([
"copy",
"move",
"rename",
"mkdir",
"hardlink",
"delete_source",
"delete_target",
"verify",
"register",
"qb_pause",
"qb_resume",
"qb_remove",
"qb_set_tags",
"other",
]);
export type OperationKind = z.infer<typeof OperationKind>;
export const OperationStatus = z.enum(["planned", "started", "succeeded", "failed", "skipped", "compensated"]);
export type OperationStatus = z.infer<typeof OperationStatus>;
/** 副作用账本:plan → attempt(started, attemptToken) → result;提交时以 attemptToken 做 fencing */
export const AssetOperation = EntityBase.extend({
operationId: z.string().min(1),
taskId: Id.nullable(),
organizationRecordId: Id.nullable(),
planFingerprint: z.string().nullable(),
ordinal: z.number().int().nonnegative(),
phase: OperationPhase,
kind: OperationKind,
status: OperationStatus,
intent: JsonObject,
attemptToken: Id.nullable(),
attempts: NonNegInt,
result: JsonObject.nullable(),
error: JsonObject.nullable(),
mediaAssetId: Id.nullable(),
downloadTaskId: Id.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type AssetOperation = z.infer<typeof AssetOperation>;
export const AssetOperationCreate = AssetOperation.pick({
operationId: true,
taskId: true,
organizationRecordId: true,
planFingerprint: true,
ordinal: true,
phase: true,
kind: true,
intent: true,
mediaAssetId: true,
downloadTaskId: true,
}).partial({
taskId: true,
organizationRecordId: true,
planFingerprint: true,
ordinal: true,
intent: true,
mediaAssetId: true,
downloadTaskId: true,
});
export type AssetOperationCreate = z.infer<typeof AssetOperationCreate>;
export const AssetOperationUpdate = AssetOperation.pick({
status: true,
attemptToken: true,
attempts: true,
result: true,
error: true,
mediaAssetId: true,
startedAt: true,
finishedAt: true,
}).partial();
export type AssetOperationUpdate = z.infer<typeof AssetOperationUpdate>;
@@ -0,0 +1,121 @@
import { z } from "zod";
import { ActorType, EntityBase, Id, JsonObject, Timestamp } from "./common";
// 身份域:外部身份映射与修订(REQ §7.2)
// provider + entityType + externalId 对 accepted 唯一;TMDB movie:123 与 tv:123 是两个身份。
export const IdentityStatus = z.enum(["accepted", "proposed", "conflicted", "retired"]);
export type IdentityStatus = z.infer<typeof IdentityStatus>;
export const ExternalEntityType = z.enum(["movie", "tv", "season", "episode", "collection", "person"]);
export type ExternalEntityType = z.infer<typeof ExternalEntityType>;
/** 证据等级:未经标注集校准前不展示概率(REQ §5.3) */
export const EvidenceLevel = z.enum(["manual", "rule_confirmed", "multi_evidence", "needs_review"]);
export type EvidenceLevel = z.infer<typeof EvidenceLevel>;
export const IdentityVerifyStatus = z.enum(["unverified", "ok", "not_found", "type_mismatch", "error"]);
export type IdentityVerifyStatus = z.infer<typeof IdentityVerifyStatus>;
export const IdentityProvider = z.string().regex(/^[a-z][a-z0-9_]*$/);
export const ExternalIdentity = EntityBase.extend({
mediaEntityId: Id.nullable(),
contentUnitId: Id.nullable(),
provider: IdentityProvider,
entityType: ExternalEntityType,
externalId: z.string().min(1),
status: IdentityStatus,
evidenceLevel: EvidenceLevel,
evidence: JsonObject,
/** 人工锁:自动匹配/刷新不得改动 */
locked: z.boolean(),
revision: z.number().int().min(1),
validFrom: Timestamp.nullable(),
validUntil: Timestamp.nullable(),
verifiedAt: Timestamp.nullable(),
/** 404/超时/鉴权失败只记录到这里,不是删除映射的依据 */
verifyStatus: IdentityVerifyStatus,
/** 附属字段,如 TMDB episode_group */
extra: JsonObject,
});
export type ExternalIdentity = z.infer<typeof ExternalIdentity>;
const oneTarget = (v: { mediaEntityId?: string | null; contentUnitId?: string | null }) =>
Number(!!v.mediaEntityId) + Number(!!v.contentUnitId) === 1;
export const ExternalIdentityCreate = ExternalIdentity.pick({
mediaEntityId: true,
contentUnitId: true,
provider: true,
entityType: true,
externalId: true,
status: true,
evidenceLevel: true,
evidence: true,
locked: true,
validFrom: true,
validUntil: true,
extra: true,
})
.partial({
mediaEntityId: true,
contentUnitId: true,
status: true,
evidenceLevel: true,
evidence: true,
locked: true,
validFrom: true,
validUntil: true,
extra: true,
})
.refine(oneTarget, { message: "mediaEntityId 与 contentUnitId 必须且只能给一个" });
export type ExternalIdentityCreate = z.infer<typeof ExternalIdentityCreate>;
/** 变更一律生成 identity_revision;expectedRevision 用于乐观并发 */
export const ExternalIdentityUpdate = z.object({
expectedRevision: z.number().int().min(1),
status: IdentityStatus.optional(),
evidenceLevel: EvidenceLevel.optional(),
evidence: JsonObject.optional(),
locked: z.boolean().optional(),
validUntil: Timestamp.nullable().optional(),
extra: JsonObject.optional(),
reason: z.string().max(2000).optional(),
});
export type ExternalIdentityUpdate = z.infer<typeof ExternalIdentityUpdate>;
export const IdentityRevisionAction = z.enum([
"created",
"status_changed",
"relinked",
"evidence_updated",
"locked",
"unlocked",
]);
export type IdentityRevisionAction = z.infer<typeof IdentityRevisionAction>;
/** 身份修订历史:只追加,不修改 */
export const IdentityRevision = EntityBase.extend({
externalIdentityId: Id,
revision: z.number().int().min(1),
action: IdentityRevisionAction,
fromStatus: IdentityStatus.nullable(),
toStatus: IdentityStatus.nullable(),
before: JsonObject.nullable(),
after: JsonObject,
actorType: ActorType,
actorId: z.string().nullable(),
reason: z.string().nullable(),
});
export type IdentityRevision = z.infer<typeof IdentityRevision>;
/** 由服务端在身份变更事务内写入;此输入仅供内部仓储使用 */
export const IdentityRevisionCreate = IdentityRevision.pick({
externalIdentityId: true,
revision: true,
action: true,
fromStatus: true,
toStatus: true,
before: true,
after: true,
actorType: true,
actorId: true,
reason: true,
}).partial({ fromStatus: true, toStatus: true, before: true, actorId: true, reason: true });
export type IdentityRevisionCreate = z.infer<typeof IdentityRevisionCreate>;
@@ -0,0 +1,16 @@
export * from "./agent";
export * from "./api";
export * from "./assets";
export * from "./capabilities-io";
export * from "./capability";
export * from "./common";
export * from "./content";
export * from "./events";
export * from "./execution";
export * from "./identity";
export * from "./instances";
export * from "./ops";
export * from "./organization";
export * from "./policy";
export * from "./settings";
export * from "./sources";
@@ -0,0 +1,161 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common";
// 实例域:媒体库根、下载器实例、媒体服务器实例、路径映射(REQ §7.1、§8.4、§12.5)
// 凭证只以 secretRef 暴露;创建/更新时的 credential 是只写明文,服务端加密为 secret 后丢弃。
export const InstanceHealth = z.enum(["unknown", "healthy", "degraded", "offline"]);
export type InstanceHealth = z.infer<typeof InstanceHealth>;
export const LibraryMediaKind = z.enum(["movie", "series", "variety", "mixed", "other"]);
export type LibraryMediaKind = z.infer<typeof LibraryMediaKind>;
/** register_only:只登记不自动维护;managed:允许按策略补齐/升级(REQ §6.6) */
export const ManagementMode = z.enum(["register_only", "managed"]);
export type ManagementMode = z.infer<typeof ManagementMode>;
export const LibraryRoot = EntityBase.extend({
name: z.string().min(1),
mediaKind: LibraryMediaKind,
/** 执行进程可访问的绝对路径 */
localPath: z.string().startsWith("/"),
managementMode: ManagementMode,
enabled: z.boolean(),
sentinelFile: z.string().nullable(),
healthStatus: InstanceHealth,
lastHealthAt: Timestamp.nullable(),
healthDetail: JsonObject,
notes: z.string().nullable(),
});
export type LibraryRoot = z.infer<typeof LibraryRoot>;
export const LibraryRootCreate = LibraryRoot.pick({
name: true,
mediaKind: true,
localPath: true,
managementMode: true,
enabled: true,
sentinelFile: true,
notes: true,
}).partial({ mediaKind: true, managementMode: true, enabled: true, sentinelFile: true, notes: true });
export type LibraryRootCreate = z.infer<typeof LibraryRootCreate>;
export const LibraryRootUpdate = LibraryRootCreate.partial();
export type LibraryRootUpdate = z.infer<typeof LibraryRootUpdate>;
/** 只写凭证:明文仅在请求中出现,绝不回显 */
const Credential = z.string().min(1).max(4096);
export const DownloaderKind = z.enum(["qbittorrent"]);
export type DownloaderKind = z.infer<typeof DownloaderKind>;
export const DownloaderInstance = EntityBase.extend({
name: z.string().min(1),
kind: DownloaderKind,
baseUrl: z.url(),
username: z.string().nullable(),
secretRef: Id.nullable(),
/** 管理标签名(REQ §8.4) */
managedTag: z.string().min(1),
/** 共享缓存卷标识:多 qB 共用同一卷时按此汇总水位 */
cacheVolumeKey: z.string().nullable(),
enabled: z.boolean(),
healthStatus: InstanceHealth,
lastSeenAt: Timestamp.nullable(),
versionInfo: JsonObject,
settings: JsonObject,
});
export type DownloaderInstance = z.infer<typeof DownloaderInstance>;
export const DownloaderInstanceCreate = DownloaderInstance.pick({
name: true,
kind: true,
baseUrl: true,
username: true,
managedTag: true,
cacheVolumeKey: true,
enabled: true,
settings: true,
})
.partial({ kind: true, username: true, managedTag: true, cacheVolumeKey: true, enabled: true, settings: true })
.extend({ credential: Credential.optional() });
export type DownloaderInstanceCreate = z.infer<typeof DownloaderInstanceCreate>;
export const DownloaderInstanceUpdate = DownloaderInstanceCreate.partial();
export type DownloaderInstanceUpdate = z.infer<typeof DownloaderInstanceUpdate>;
export const MediaServerKind = z.enum(["emby", "jellyfin"]);
export type MediaServerKind = z.infer<typeof MediaServerKind>;
export const MediaServerInstance = EntityBase.extend({
name: z.string().min(1),
kind: MediaServerKind,
baseUrl: z.url(),
userId: z.string().nullable(),
secretRef: Id.nullable(),
enabled: z.boolean(),
healthStatus: InstanceHealth,
lastSeenAt: Timestamp.nullable(),
versionInfo: JsonObject,
settings: JsonObject,
});
export type MediaServerInstance = z.infer<typeof MediaServerInstance>;
export const MediaServerInstanceCreate = MediaServerInstance.pick({
name: true,
kind: true,
baseUrl: true,
userId: true,
enabled: true,
settings: true,
})
.partial({ kind: true, userId: true, enabled: true, settings: true })
.extend({ credential: Credential.optional() });
export type MediaServerInstanceCreate = z.infer<typeof MediaServerInstanceCreate>;
export const MediaServerInstanceUpdate = MediaServerInstanceCreate.partial();
export type MediaServerInstanceUpdate = z.infer<typeof MediaServerInstanceUpdate>;
export const PathViewKind = z.enum(["downloader", "media_server"]);
export type PathViewKind = z.infer<typeof PathViewKind>;
export const PathMappingVerifyStatus = z.enum(["unverified", "ok", "failed"]);
export type PathMappingVerifyStatus = z.infer<typeof PathMappingVerifyStatus>;
/** 下载器/媒体服务器路径视图 → 执行进程可访问路径的前缀映射(REQ §12.5) */
export const PathMapping = EntityBase.extend({
viewKind: PathViewKind,
downloaderInstanceId: Id.nullable(),
mediaServerInstanceId: Id.nullable(),
libraryRootId: Id.nullable(),
remotePrefix: z.string().min(1),
localPrefix: z.string().startsWith("/"),
priority: z.number().int(),
enabled: z.boolean(),
verifyStatus: PathMappingVerifyStatus,
verifiedAt: Timestamp.nullable(),
verifyDetail: JsonObject,
});
export type PathMapping = z.infer<typeof PathMapping>;
const pathMappingOwnerOk = (v: {
viewKind?: PathViewKind;
downloaderInstanceId?: string | null;
mediaServerInstanceId?: string | null;
}) =>
v.viewKind === undefined ||
(v.viewKind === "downloader"
? !!v.downloaderInstanceId && !v.mediaServerInstanceId
: !!v.mediaServerInstanceId && !v.downloaderInstanceId);
const PathMappingInput = PathMapping.omit({
...SERVER_MANAGED,
verifyStatus: true,
verifiedAt: true,
verifyDetail: true,
});
export const PathMappingCreate = PathMappingInput.partial({
downloaderInstanceId: true,
mediaServerInstanceId: true,
libraryRootId: true,
priority: true,
enabled: true,
}).refine(pathMappingOwnerOk, { message: "viewKind 与实例 ID 不匹配" });
export type PathMappingCreate = z.infer<typeof PathMappingCreate>;
export const PathMappingUpdate = PathMappingInput.partial().refine(pathMappingOwnerOk, {
message: "viewKind 与实例 ID 不匹配",
});
export type PathMappingUpdate = z.infer<typeof PathMappingUpdate>;
+194
View File
@@ -0,0 +1,194 @@
import { z } from "zod";
import { ActorType, ApiScope, EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
// 运维域:校验运行、媒体服务器库存映射、API Key、审计、outbox、凭证(REQ §8.1、§7.4、§13.2)
export const ReconcileKind = z.enum(["identity", "metadata", "asset", "acquisition", "server"]);
export type ReconcileKind = z.infer<typeof ReconcileKind>;
/** read_only:恢复后默认只读对账模式(REQ §8.5) */
export const ReconcileMode = z.enum(["normal", "read_only"]);
export type ReconcileMode = z.infer<typeof ReconcileMode>;
export const ReconcileStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]);
export type ReconcileStatus = z.infer<typeof ReconcileStatus>;
export const ReconcileRun = EntityBase.extend({
kind: ReconcileKind,
mode: ReconcileMode,
status: ReconcileStatus,
scope: JsonObject,
/** 中断续扫位置 */
resumeCursor: JsonObject,
stats: JsonObject,
taskId: Id.nullable(),
error: JsonObject.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type ReconcileRun = z.infer<typeof ReconcileRun>;
export const ReconcileRunCreate = ReconcileRun.pick({ kind: true, mode: true, scope: true }).partial({
mode: true,
scope: true,
});
export type ReconcileRunCreate = z.infer<typeof ReconcileRunCreate>;
export const ReconcileRunUpdate = ReconcileRun.pick({
status: true,
resumeCursor: true,
stats: true,
taskId: true,
error: true,
startedAt: true,
finishedAt: true,
}).partial();
export type ReconcileRunUpdate = z.infer<typeof ReconcileRunUpdate>;
export const ServerItemType = z.enum(["movie", "series", "season", "episode", "other"]);
export type ServerItemType = z.infer<typeof ServerItemType>;
/** 未知 ≠ 缺失:stale/unmatched 不能推出"没有资源" */
export const ServerMatchStatus = z.enum(["matched", "ambiguous", "unmatched", "stale"]);
export type ServerMatchStatus = z.infer<typeof ServerMatchStatus>;
/** Emby 兼容库存条目与本地对象的映射(辅助证据,不构成文件纳管授权) */
export const ServerMapping = EntityBase.extend({
mediaServerInstanceId: Id,
serverItemId: z.string().min(1),
itemType: ServerItemType,
mediaEntityId: Id.nullable(),
contentUnitId: Id.nullable(),
mediaAssetId: Id.nullable(),
serverPath: z.string().nullable(),
pathMappingId: Id.nullable(),
matchStatus: ServerMatchStatus,
versionInfo: JsonObject,
evidence: JsonObject,
observedAt: Timestamp,
});
export type ServerMapping = z.infer<typeof ServerMapping>;
export const ServerMappingCreate = ServerMapping.omit(SERVER_MANAGED).partial({
mediaEntityId: true,
contentUnitId: true,
mediaAssetId: true,
serverPath: true,
pathMappingId: true,
matchStatus: true,
versionInfo: true,
evidence: true,
observedAt: true,
});
export type ServerMappingCreate = z.infer<typeof ServerMappingCreate>;
export const ServerMappingUpdate = ServerMappingCreate.omit({
mediaServerInstanceId: true,
serverItemId: true,
}).partial();
export type ServerMappingUpdate = z.infer<typeof ServerMappingUpdate>;
/** API Key(不含 key_hash) */
export const ApiKey = EntityBase.extend({
name: z.string().min(1),
scopes: z.array(ApiScope),
revokedAt: Timestamp.nullable(),
lastUsedAt: Timestamp.nullable(),
});
export type ApiKey = z.infer<typeof ApiKey>;
export const ApiKeyCreate = z.object({
name: z.string().min(1).max(80),
scopes: z.array(ApiScope).min(1),
});
export type ApiKeyCreate = z.infer<typeof ApiKeyCreate>;
/** 创建响应:明文 key 只在此返回一次 */
export const ApiKeyCreated = z.object({ apiKey: ApiKey, plaintextKey: z.string().min(16) });
export type ApiKeyCreated = z.infer<typeof ApiKeyCreated>;
export const ApiKeyUpdate = z.object({
name: z.string().min(1).max(80).optional(),
scopes: z.array(ApiScope).min(1).optional(),
revoked: z.literal(true).optional(),
});
export type ApiKeyUpdate = z.infer<typeof ApiKeyUpdate>;
/** 审计事件(只追加) */
export const AuditEvent = z.object({
id: Id,
at: Timestamp,
actorType: z.string(),
actorId: z.string().nullable(),
action: z.string().min(1),
targetType: z.string().nullable(),
targetId: z.string().nullable(),
payload: JsonObject,
traceId: z.string().nullable(),
});
export type AuditEvent = z.infer<typeof AuditEvent>;
export const AuditEventCreate = AuditEvent.omit({ id: true, at: true })
.partial({ actorId: true, targetType: true, targetId: true, payload: true, traceId: true })
.extend({ actorType: ActorType });
export type AuditEventCreate = z.infer<typeof AuditEventCreate>;
export const OutboxStatus = z.enum(["pending", "dispatched", "failed", "dead"]);
export type OutboxStatus = z.infer<typeof OutboxStatus>;
/** outbox:业务事务内写入,分发器轮询 pending 并以 idempotencyKey 入队(ADR-002 §2.1) */
export const OutboxEvent = z.object({
id: Id,
at: Timestamp,
type: z.string().min(1),
payload: JsonObject,
publishedAt: Timestamp.nullable(),
idempotencyKey: z.string().nullable(),
status: OutboxStatus,
attempts: NonNegInt,
nextAttemptAt: Timestamp,
lastError: JsonObject.nullable(),
});
export type OutboxEvent = z.infer<typeof OutboxEvent>;
export const OutboxEventCreate = OutboxEvent.pick({
type: true,
payload: true,
idempotencyKey: true,
nextAttemptAt: true,
}).partial({
payload: true,
idempotencyKey: true,
nextAttemptAt: true,
});
export type OutboxEventCreate = z.infer<typeof OutboxEventCreate>;
export const OutboxEventUpdate = OutboxEvent.pick({
status: true,
attempts: true,
nextAttemptAt: true,
lastError: true,
publishedAt: true,
}).partial();
export type OutboxEventUpdate = z.infer<typeof OutboxEventUpdate>;
export const SecretPurpose = z.enum([
"site_cookie",
"site_passkey",
"site_api_key",
"site_password",
"feed_url",
"downloader_password",
"media_server_api_key",
"metadata_api_key",
"llm_api_key",
"other",
]);
export type SecretPurpose = z.infer<typeof SecretPurpose>;
/** 加密凭证的元数据视图:密文/nonce 永不出 API */
export const Secret = EntityBase.extend({
name: z.string().min(1),
purpose: SecretPurpose,
algorithm: z.string(),
kdf: z.string(),
kdfParams: JsonObject,
keyVersion: z.number().int().min(1),
rotatedAt: Timestamp.nullable(),
});
export type Secret = z.infer<typeof Secret>;
/** plaintext 只写;服务端用 SECRET_MASTER_KEY 派生密钥加密后存 ciphertext/nonce */
export const SecretCreate = z.object({
name: z.string().min(1).max(120),
purpose: SecretPurpose,
plaintext: z.string().min(1).max(16384),
});
export type SecretCreate = z.infer<typeof SecretCreate>;
export const SecretUpdate = z.object({ plaintext: z.string().min(1).max(16384) });
export type SecretUpdate = z.infer<typeof SecretUpdate>;
@@ -0,0 +1,288 @@
import { z } from "zod";
import { ActorType, EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common";
import { MediaKind } from "./content";
// 整理域:整理记录(批次)+ 逐文件结果,以及对外开放的整理记录 API 合同(REQ §12.4)
// 自动与手动整理产生同格式记录;读记录需 organize.read,发起整理需 organize.execute。
export const OrganizationTrigger = z.enum(["auto", "manual", "agent"]);
export type OrganizationTrigger = z.infer<typeof OrganizationTrigger>;
export const OrganizationStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]);
export type OrganizationStatus = z.infer<typeof OrganizationStatus>;
/** Copy / Move / 重命名 / 目录调整 / 硬链接(仅兼容既有) */
export const OrganizationAction = z.enum(["copy", "move", "rename", "relocate", "hardlink"]);
export type OrganizationAction = z.infer<typeof OrganizationAction>;
/** 源路径所在视图:下载器视角 / 执行进程本地 / 库根相对 */
export const SourcePathView = z.enum(["downloader", "local", "library"]);
export type SourcePathView = z.infer<typeof SourcePathView>;
export const FileRole = z.enum(["main", "subtitle", "audio", "bluray_dir", "nfo", "image", "other"]);
export type FileRole = z.infer<typeof FileRole>;
export const FileResultStatus = z.enum(["queued", "running", "succeeded", "failed", "skipped", "cancelled"]);
export type FileResultStatus = z.infer<typeof FileResultStatus>;
/** 稳定变更游标:bigint 序号的十进制字符串 */
export const ChangeSeq = z.number().int().nonnegative();
export const ChangeCursor = z.string().regex(/^\d{1,16}$/);
export type ChangeCursor = z.infer<typeof ChangeCursor>;
// ── 内部行表示(与 organization_record / organization_file_result 列一一对应,不含 change_xid) ──
export const OrganizationRecord = EntityBase.extend({
taskId: Id.nullable(),
trigger: OrganizationTrigger,
actorType: ActorType,
actorId: z.string().nullable(),
mediaEntityId: Id.nullable(),
contentUnitId: Id.nullable(),
downloadTaskId: Id.nullable(),
sourceDownloaderInstanceId: Id.nullable(),
sourcePathView: SourcePathView,
sourcePath: z.string().nullable(),
targetLibraryRootId: Id.nullable(),
targetPath: z.string().nullable(),
action: OrganizationAction,
status: OrganizationStatus,
reason: z.string().nullable(),
error: JsonObject.nullable(),
/** 冻结的整理计划 */
plan: JsonObject,
planFingerprint: z.string().nullable(),
fileTotal: NonNegInt,
fileSucceeded: NonNegInt,
fileFailed: NonNegInt,
fileSkipped: NonNegInt,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
changeSeq: ChangeSeq,
});
export type OrganizationRecord = z.infer<typeof OrganizationRecord>;
export const OrganizationRecordCreate = OrganizationRecord.pick({
taskId: true,
trigger: true,
actorType: true,
actorId: true,
mediaEntityId: true,
contentUnitId: true,
downloadTaskId: true,
sourceDownloaderInstanceId: true,
sourcePathView: true,
sourcePath: true,
targetLibraryRootId: true,
targetPath: true,
action: true,
plan: true,
planFingerprint: true,
}).partial({
taskId: true,
actorId: true,
mediaEntityId: true,
contentUnitId: true,
downloadTaskId: true,
sourceDownloaderInstanceId: true,
sourcePathView: true,
sourcePath: true,
targetLibraryRootId: true,
targetPath: true,
plan: true,
planFingerprint: true,
});
export type OrganizationRecordCreate = z.infer<typeof OrganizationRecordCreate>;
export const OrganizationRecordUpdate = OrganizationRecord.pick({
status: true,
reason: true,
error: true,
targetPath: true,
fileTotal: true,
fileSucceeded: true,
fileFailed: true,
fileSkipped: true,
startedAt: true,
finishedAt: true,
}).partial();
export type OrganizationRecordUpdate = z.infer<typeof OrganizationRecordUpdate>;
export const OrganizationFileResult = EntityBase.extend({
organizationRecordId: Id,
ordinal: z.number().int().nonnegative(),
assetOperationId: Id.nullable(),
mediaAssetId: Id.nullable(),
contentUnitId: Id.nullable(),
role: FileRole,
mode: OrganizationAction,
sourcePath: z.string().min(1),
sourceSizeBytes: NonNegInt.nullable(),
targetLibraryRootId: Id.nullable(),
targetPath: z.string().nullable(),
status: FileResultStatus,
skipReason: z.string().nullable(),
error: JsonObject.nullable(),
checksum: z.string().nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type OrganizationFileResult = z.infer<typeof OrganizationFileResult>;
export const OrganizationFileResultCreate = OrganizationFileResult.pick({
organizationRecordId: true,
ordinal: true,
assetOperationId: true,
contentUnitId: true,
role: true,
mode: true,
sourcePath: true,
sourceSizeBytes: true,
targetLibraryRootId: true,
targetPath: true,
}).partial({
ordinal: true,
assetOperationId: true,
contentUnitId: true,
role: true,
sourceSizeBytes: true,
targetLibraryRootId: true,
targetPath: true,
});
export type OrganizationFileResultCreate = z.infer<typeof OrganizationFileResultCreate>;
export const OrganizationFileResultUpdate = OrganizationFileResult.pick({
assetOperationId: true,
mediaAssetId: true,
targetPath: true,
status: true,
skipReason: true,
error: true,
checksum: true,
startedAt: true,
finishedAt: true,
}).partial();
export type OrganizationFileResultUpdate = z.infer<typeof OrganizationFileResultUpdate>;
// ── 对外开放的整理记录合同(外部程序据此适配媒体服务器) ──
/** 失败/跳过原因:稳定 code + 可读说明;不含凭据与私有 URL */
export const PublicReason = z.object({
code: z.string().min(1),
message: z.string(),
});
export type PublicReason = z.infer<typeof PublicReason>;
/** 路径端点:路径 + 所在视图标识,避免跨实例同路径歧义 */
export const PublicPathRef = z.object({
path: z.string().nullable(),
view: z.enum(["downloader", "local", "library"]),
downloaderInstanceId: Id.nullable(),
libraryRootId: Id.nullable(),
});
export type PublicPathRef = z.infer<typeof PublicPathRef>;
export const PublicOrganizationFile = z.object({
id: Id,
ordinal: z.number().int().nonnegative(),
role: FileRole,
mode: OrganizationAction,
status: FileResultStatus,
source: PublicPathRef,
target: PublicPathRef,
sizeBytes: NonNegInt.nullable(),
contentUnitId: Id.nullable(),
mediaAssetId: Id.nullable(),
operationId: Id.nullable(),
skipReason: PublicReason.nullable(),
failure: PublicReason.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type PublicOrganizationFile = z.infer<typeof PublicOrganizationFile>;
export const PublicOrganizationRecord = z.object({
/** 稳定记录 ID */
id: Id,
taskId: Id.nullable(),
/** 相关资产操作账本 ID */
operationIds: z.array(Id),
media: z
.object({
entityId: Id,
kind: MediaKind,
title: z.string(),
year: z.number().int().nullable(),
contentUnitId: Id.nullable(),
seasonNumber: z.number().int().nullable(),
episodeNumber: z.number().int().nullable(),
/** 已确认的外部身份,如 tmdb:movie:693134 */
externalIds: z.array(z.object({ provider: z.string(), entityType: z.string(), externalId: z.string() })),
})
.nullable(),
trigger: OrganizationTrigger,
/** 手动 / 自动来源(agent 视为自动) */
origin: z.enum(["manual", "auto"]),
actor: z.object({ type: ActorType, id: z.string().nullable() }),
action: OrganizationAction,
status: OrganizationStatus,
source: PublicPathRef,
target: PublicPathRef,
reason: PublicReason.nullable(),
counts: z.object({ total: NonNegInt, succeeded: NonNegInt, failed: NonNegInt, skipped: NonNegInt }),
/** includeFiles=false 时为 null */
files: z.array(PublicOrganizationFile).nullable(),
createdAt: Timestamp,
updatedAt: Timestamp,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
/** 该记录最近一次变更的游标值;客户端按 id 去重、以最大 changeSeq 续读 */
changeSeq: ChangeCursor,
});
export type PublicOrganizationRecord = z.infer<typeof PublicOrganizationRecord>;
/** 增量读取请求:after 为上次响应的 nextCursor(首读省略即从头) */
export const OrganizationChangesQuery = z.object({
after: ChangeCursor.optional(),
limit: z.coerce.number().int().min(1).max(500).default(100),
includeFiles: z.union([z.boolean(), z.enum(["true", "false"]).transform((v) => v === "true")]).default(true),
status: OrganizationStatus.optional(),
mediaEntityId: Id.optional(),
});
export type OrganizationChangesQuery = z.infer<typeof OrganizationChangesQuery>;
/**
* 增量读取响应:items 按 changeSeq 升序;nextCursor 总是返回(无新数据时等于请求的 after),
* hasMore=true 表示应立即继续拉取。同一记录状态更新会以更大的 changeSeq 再次出现。
*/
export const OrganizationChangesResponse = z.object({
items: z.array(PublicOrganizationRecord),
nextCursor: ChangeCursor,
hasMore: z.boolean(),
});
export type OrganizationChangesResponse = z.infer<typeof OrganizationChangesResponse>;
/** 列表查询(控制台):按创建时间倒序的不透明游标分页 */
export const OrganizationListQuery = z.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
status: OrganizationStatus.optional(),
trigger: OrganizationTrigger.optional(),
mediaEntityId: Id.optional(),
q: z.string().max(200).optional(),
});
export type OrganizationListQuery = z.infer<typeof OrganizationListQuery>;
/** 手动发起整理(organize.execute):返回 record/task ID,由任务页追踪 */
export const OrganizeRequest = z
.object({
downloadTaskId: Id.optional(),
/** 执行进程可访问的源路径(与 downloadTaskId 二选一) */
sourcePaths: z.array(z.string().startsWith("/")).min(1).max(1000).optional(),
mediaEntityId: Id.optional(),
contentUnitId: Id.optional(),
action: z.enum(["copy", "move"]),
targetLibraryRootId: Id.optional(),
/** true 只生成计划(organize.plan),不执行 */
dryRun: z.boolean().default(false),
})
.refine((v) => !!v.downloadTaskId !== !!v.sourcePaths, { message: "downloadTaskId 与 sourcePaths 必须二选一" });
export type OrganizeRequest = z.infer<typeof OrganizeRequest>;
export const OrganizeAccepted = z.object({
recordId: Id.nullable(),
taskId: Id,
planId: Id.nullable(),
});
export type OrganizeAccepted = z.infer<typeof OrganizeAccepted>;
@@ -0,0 +1,247 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common";
// 规则域:订阅、策略版本、收录 Slot、偏好标签与抑制记录(REQ §6)
export const PolicyKind = z.enum(["acquisition", "preference", "retention", "cleanup", "instance", "automation"]);
export type PolicyKind = z.infer<typeof PolicyKind>;
export const PolicyStatus = z.enum(["draft", "published", "superseded", "rolled_back", "archived"]);
export type PolicyStatus = z.infer<typeof PolicyStatus>;
/** 策略版本:草案 → 预览 → 授权发布 → 版本化/回滚;同一 policyKey 只有一个 published */
export const PolicyRevision = EntityBase.extend({
policyKey: z.string().min(1),
revision: z.number().int().min(1),
kind: PolicyKind,
status: PolicyStatus,
title: z.string().min(1),
summary: z.string().nullable(),
scope: JsonObject,
/** 结构化规则数据;不是可执行脚本或工作流 DSL */
rules: JsonObject,
preview: JsonObject.nullable(),
basedOnRevisionId: Id.nullable(),
createdBy: z.string().nullable(),
publishedAt: Timestamp.nullable(),
publishedBy: z.string().nullable(),
});
export type PolicyRevision = z.infer<typeof PolicyRevision>;
/** 新建总是草案;revision 由服务端分配 */
export const PolicyRevisionCreate = PolicyRevision.pick({
policyKey: true,
kind: true,
title: true,
summary: true,
scope: true,
rules: true,
basedOnRevisionId: true,
}).partial({ summary: true, scope: true, basedOnRevisionId: true });
export type PolicyRevisionCreate = z.infer<typeof PolicyRevisionCreate>;
/** 只有草案可改内容;发布/回滚走动作端点 */
export const PolicyRevisionUpdate = PolicyRevisionCreate.omit({ policyKey: true, kind: true }).partial();
export type PolicyRevisionUpdate = z.infer<typeof PolicyRevisionUpdate>;
export const SubscriptionMediaKind = z.enum(["movie", "series", "variety"]);
export type SubscriptionMediaKind = z.infer<typeof SubscriptionMediaKind>;
export const SubscriptionMode = z.enum(["once", "continuous"]);
export type SubscriptionMode = z.infer<typeof SubscriptionMode>;
/** 仅本季 / 持续后续季(REQ §6.5) */
export const SeasonScope = z.enum(["current_season", "following_seasons"]);
export type SeasonScope = z.infer<typeof SeasonScope>;
export const SubscriptionOrigin = z.enum(["user", "agent", "policy", "import"]);
export type SubscriptionOrigin = z.infer<typeof SubscriptionOrigin>;
/** 默认优先级:即时 > 追更 > 新作品 > 升级(REQ §6.8) */
export const AcquisitionPriority = z.enum(["immediate", "tracking", "new_work", "upgrade"]);
export type AcquisitionPriority = z.infer<typeof AcquisitionPriority>;
export const SubscriptionStatus = z.enum(["active", "paused", "completed", "cancelled"]);
export type SubscriptionStatus = z.infer<typeof SubscriptionStatus>;
export const Subscription = EntityBase.extend({
mediaEntityId: Id.nullable(),
seasonUnitId: Id.nullable(),
/** 作品尚未建档时的检索词 */
titleQuery: z.string().nullable(),
mediaKind: SubscriptionMediaKind,
mode: SubscriptionMode,
seasonScope: SeasonScope,
origin: SubscriptionOrigin,
priority: AcquisitionPriority,
status: SubscriptionStatus,
policyRevisionId: Id.nullable(),
params: JsonObject,
statusReason: z.string().nullable(),
lastCheckedAt: Timestamp.nullable(),
createdBy: z.string().nullable(),
});
export type Subscription = z.infer<typeof Subscription>;
export const SubscriptionCreate = Subscription.pick({
mediaEntityId: true,
seasonUnitId: true,
titleQuery: true,
mediaKind: true,
mode: true,
seasonScope: true,
origin: true,
priority: true,
policyRevisionId: true,
params: true,
})
.partial({
mediaEntityId: true,
seasonUnitId: true,
titleQuery: true,
mode: true,
seasonScope: true,
origin: true,
priority: true,
policyRevisionId: true,
params: true,
})
.refine((v) => !!v.mediaEntityId || !!v.titleQuery, { message: "mediaEntityId 与 titleQuery 至少给一个" });
export type SubscriptionCreate = z.infer<typeof SubscriptionCreate>;
export const SubscriptionUpdate = Subscription.pick({
mode: true,
seasonScope: true,
priority: true,
status: true,
policyRevisionId: true,
params: true,
statusReason: true,
}).partial();
export type SubscriptionUpdate = z.infer<typeof SubscriptionUpdate>;
/** Slot 状态(REQ §6.1) */
export const SlotStatus = z.enum([
"empty",
"reserved",
"downloading",
"verifying",
"satisfied",
"upgrade_pending",
"attention",
]);
export type SlotStatus = z.infer<typeof SlotStatus>;
/** 独立收录目标:硬条件 / 软偏好 / 回退 / 升级阈值 / 完成条件 */
export const AcquisitionSlot = EntityBase.extend({
mediaEntityId: Id,
contentUnitId: Id.nullable(),
subscriptionId: Id.nullable(),
policyRevisionId: Id.nullable(),
/** 用户命名,如 archival / streaming */
name: z.string().min(1).max(64),
status: SlotStatus,
hardConditions: JsonObject,
softPreferences: JsonObject,
fallback: JsonObject,
upgradeThreshold: JsonObject,
completion: JsonObject,
/** 同组 Slot 在 requireDistinctAsset=true 时必须由不同物理资产满足(M2 校验) */
distinctGroup: z.string().nullable(),
requireDistinctAsset: z.boolean(),
currentAssetId: Id.nullable(),
reservedPlanId: Id.nullable(),
statusReason: z.string().nullable(),
lastEvaluatedAt: Timestamp.nullable(),
nextEvaluateAt: Timestamp.nullable(),
revision: z.number().int().min(1),
});
export type AcquisitionSlot = z.infer<typeof AcquisitionSlot>;
export const AcquisitionSlotCreate = AcquisitionSlot.pick({
mediaEntityId: true,
contentUnitId: true,
subscriptionId: true,
policyRevisionId: true,
name: true,
hardConditions: true,
softPreferences: true,
fallback: true,
upgradeThreshold: true,
completion: true,
distinctGroup: true,
requireDistinctAsset: true,
}).partial({
contentUnitId: true,
subscriptionId: true,
policyRevisionId: true,
hardConditions: true,
softPreferences: true,
fallback: true,
upgradeThreshold: true,
completion: true,
distinctGroup: true,
requireDistinctAsset: true,
});
export type AcquisitionSlotCreate = z.infer<typeof AcquisitionSlotCreate>;
/** 规则可改;status/currentAsset/reservedPlan 只由状态机推进,不经 Update */
export const AcquisitionSlotUpdate = AcquisitionSlotCreate.omit({
mediaEntityId: true,
contentUnitId: true,
name: true,
})
.partial()
.extend({ expectedRevision: z.number().int().min(1) });
export type AcquisitionSlotUpdate = z.infer<typeof AcquisitionSlotUpdate>;
/** 偏好类型:soft 影响排序;hard 硬排除;version 版本偏好;target 独立目标配置(REQ §6.7) */
export const PreferenceKind = z.enum(["soft", "hard", "version", "target"]);
export type PreferenceKind = z.infer<typeof PreferenceKind>;
export const PreferenceOrigin = z.enum(["manual", "conversation", "agent_inferred"]);
export type PreferenceOrigin = z.infer<typeof PreferenceOrigin>;
export const PreferenceStatus = z.enum(["active", "disabled", "deleted"]);
export type PreferenceStatus = z.infer<typeof PreferenceStatus>;
export const PreferenceTag = EntityBase.extend({
label: z.string().min(1).max(80),
kind: PreferenceKind,
scope: JsonObject,
rule: JsonObject,
/** 语义指纹(kind+scope+rule 规范化哈希),用于抑制匹配 */
fingerprint: z.string().min(1),
origin: PreferenceOrigin,
description: z.string().nullable(),
status: PreferenceStatus,
revision: z.number().int().min(1),
evidence: JsonObject,
policyRevisionId: Id.nullable(),
});
export type PreferenceTag = z.infer<typeof PreferenceTag>;
/** fingerprint 由服务端计算 */
export const PreferenceTagCreate = PreferenceTag.pick({
label: true,
kind: true,
scope: true,
rule: true,
origin: true,
description: true,
evidence: true,
}).partial({ scope: true, rule: true, origin: true, description: true, evidence: true });
export type PreferenceTagCreate = z.infer<typeof PreferenceTagCreate>;
export const PreferenceTagUpdate = PreferenceTagCreate.omit({ origin: true })
.partial()
.extend({ status: PreferenceStatus.optional() });
export type PreferenceTagUpdate = z.infer<typeof PreferenceTagUpdate>;
/** 手动取消标签留下的抑制记录:Agent 不得据同一旧证据自动加回 */
export const PreferenceSuppression = EntityBase.extend({
preferenceTagId: Id.nullable(),
fingerprint: z.string().min(1),
reason: z.string().nullable(),
evidence: JsonObject,
suppressedBy: z.string().min(1),
active: z.boolean(),
liftedAt: Timestamp.nullable(),
liftedBy: z.string().nullable(),
});
export type PreferenceSuppression = z.infer<typeof PreferenceSuppression>;
export const PreferenceSuppressionCreate = PreferenceSuppression.omit({
...SERVER_MANAGED,
active: true,
liftedAt: true,
liftedBy: true,
suppressedBy: true,
}).partial({ preferenceTagId: true, reason: true, evidence: true });
export type PreferenceSuppressionCreate = z.infer<typeof PreferenceSuppressionCreate>;
/** 唯一允许的更新是解除抑制 */
export const PreferenceSuppressionUpdate = z.object({ active: z.literal(false), reason: z.string().optional() });
export type PreferenceSuppressionUpdate = z.infer<typeof PreferenceSuppressionUpdate>;
@@ -0,0 +1,157 @@
import { z } from "zod";
import { Id, Timestamp } from "./common";
// 全局设置:存于 settings 表(key → jsonb)。每个顶层分组对应一个 settings.key。
// schema 本身不带 .default()(zod 4 在 partial 里仍会套默认值,会让 PATCH 覆盖现值);
// 缺省值统一放在 DEFAULT_SETTINGS,服务端读取时 `{ ...DEFAULT_SETTINGS[key], ...stored }` 后再 parse。
const Percent = z.number().min(1).max(100);
export const AutomationSettings = z.object({
/** 自动化总开关:关闭后所有自动收录/下载/整理停止,只读查询与手动操作可用 */
enabled: z.boolean(),
/** 恢复后的只读对账模式(REQ §8.5):开启时禁止自动写入 */
readOnlyReconcile: z.boolean(),
});
export type AutomationSettings = z.infer<typeof AutomationSettings>;
const CacheShape = z.object({
/** 缓存卷使用率硬阈值:达到即停止新增下载并暂停纳管下载中任务 */
hardThresholdPercent: Percent,
/** 恢复水位:降到此值以下恢复因阈值暂停的任务 */
resumePercent: Percent,
checkIntervalSec: z.number().int().min(10).max(3600),
/** 缓存清理的目标使用率(低水位) */
cleanupTargetPercent: Percent,
});
/** 下载缓存卷硬阈值与恢复水位(REQ §8.4.1) */
export const CacheSettings = CacheShape.refine((v) => v.resumePercent < v.hardThresholdPercent, {
message: "恢复水位必须低于硬阈值",
}).refine((v) => v.cleanupTargetPercent <= v.resumePercent, { message: "清理目标不得高于恢复水位" });
export type CacheSettings = z.infer<typeof CacheSettings>;
export const OrganizeMode = z.enum(["copy", "move"]);
export type OrganizeMode = z.infer<typeof OrganizeMode>;
export const OrganizeSettings = z.object({
defaultMode: OrganizeMode,
/** 跨卷 Move 采用 Copy→核验→删源;EXDEV 不静默降级 */
allowCrossVolumeMove: z.boolean(),
});
export type OrganizeSettings = z.infer<typeof OrganizeSettings>;
export const RetentionSettings = z.object({
/** Copy 入库后下载源在缓存中至少保留的天数(与 H&R 要求分开判断) */
seedRetentionDays: z.number().int().min(0).max(365),
/** 观察/日志类数据保留天数 */
logRetentionDays: z.number().int().min(1).max(3650),
});
export type RetentionSettings = z.infer<typeof RetentionSettings>;
export const ThinkingLevel = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]);
export type ThinkingLevel = z.infer<typeof ThinkingLevel>;
/** Pi Agent 设置(REQ §10.5):上游/模型/思考档位/上下文预算/压缩/子代理;密钥只存 secret 引用 */
export const PiSettings = z.object({
provider: z.string().min(1),
baseUrl: z.url().nullable(),
model: z.string().min(1).nullable(),
thinkingLevel: ThinkingLevel,
/** 上下文预算(token) */
contextBudget: z.number().int().min(4096).max(2_000_000),
compaction: z.object({
enabled: z.boolean(),
/** 达到上下文预算的该比例时触发压缩 */
thresholdRatio: z.number().min(0.3).max(0.95),
}),
subagentsEnabled: z.boolean(),
apiKeySecretRef: Id.nullable(),
});
export type PiSettings = z.infer<typeof PiSettings>;
/** 每日预算与并发(REQ §6.4、§10.5) */
export const BudgetSettings = z.object({
maxNewTitlesPerDay: z.number().int().min(0),
maxDownloadBytesPerDay: z.number().int().min(0),
maxConcurrentDownloads: z.number().int().min(1).max(100),
agentMaxRunsPerDay: z.number().int().min(0),
agentMaxCostUsdPerDay: z.number().min(0),
});
export type BudgetSettings = z.infer<typeof BudgetSettings>;
export const GlobalSettings = z.object({
automation: AutomationSettings,
cache: CacheSettings,
organize: OrganizeSettings,
retention: RetentionSettings,
pi: PiSettings,
budget: BudgetSettings,
});
export type GlobalSettings = z.infer<typeof GlobalSettings>;
/** settings 表的合法 key */
export const SettingsKey = GlobalSettings.keyof();
export type SettingsKey = z.infer<typeof SettingsKey>;
/** 缺省设置(settings 表缺 key 或缺字段时使用) */
export const DEFAULT_SETTINGS: GlobalSettings = {
automation: { enabled: true, readOnlyReconcile: false },
cache: { hardThresholdPercent: 90, resumePercent: 85, checkIntervalSec: 60, cleanupTargetPercent: 80 },
organize: { defaultMode: "copy", allowCrossVolumeMove: true },
retention: { seedRetentionDays: 7, logRetentionDays: 180 },
pi: {
provider: "anthropic",
baseUrl: null,
model: null,
thinkingLevel: "medium",
contextBudget: 128_000,
compaction: { enabled: true, thresholdRatio: 0.8 },
subagentsEnabled: false,
apiKeySecretRef: null,
},
budget: {
maxNewTitlesPerDay: 20,
maxDownloadBytesPerDay: 500 * 1024 ** 3,
maxConcurrentDownloads: 5,
agentMaxRunsPerDay: 50,
agentMaxCostUsdPerDay: 5,
},
};
/** PATCH /settings:按分组部分更新;服务端与现值合并后用 GlobalSettings 整体再校验 */
export const GlobalSettingsUpdate = z.object({
automation: AutomationSettings.partial().optional(),
cache: CacheShape.partial().optional(),
organize: OrganizeSettings.partial().optional(),
retention: RetentionSettings.partial().optional(),
pi: PiSettings.omit({ apiKeySecretRef: true })
.partial()
.extend({
compaction: PiSettings.shape.compaction.partial().optional(),
/** 只写:明文上游密钥,服务端存为 secret 并回填 apiKeySecretRef;传 null 表示清除 */
apiKey: z.string().min(1).max(4096).nullable().optional(),
})
.optional(),
budget: BudgetSettings.partial().optional(),
});
export type GlobalSettingsUpdate = z.infer<typeof GlobalSettingsUpdate>;
/** 合并部分更新(纯函数,web 乐观更新与 server 共用) */
export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpdate): GlobalSettings {
const { apiKey: _apiKey, compaction, ...pi } = patch.pi ?? {};
return {
automation: { ...current.automation, ...patch.automation },
cache: { ...current.cache, ...patch.cache },
organize: { ...current.organize, ...patch.organize },
retention: { ...current.retention, ...patch.retention },
pi: { ...current.pi, ...pi, compaction: { ...current.pi.compaction, ...compaction } },
budget: { ...current.budget, ...patch.budget },
};
}
/** settings 表行 */
export const SettingEntry = z.object({
key: SettingsKey,
value: z.unknown(),
updatedAt: Timestamp,
});
export type SettingEntry = z.infer<typeof SettingEntry>;
@@ -0,0 +1,261 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common";
// 来源域:站点、站点账号、Feed、站点资源、观察(REQ §4)
// 含 passkey 的 Feed URL 只以 urlSecretRef 保存,对外只给脱敏的 urlDisplay。
export const SiteAdapter = z.enum(["nexusphp", "mteam_api", "generic_rss"]);
export type SiteAdapter = z.infer<typeof SiteAdapter>;
export const SiteHealth = z.enum(["unknown", "healthy", "degraded", "failing", "disabled"]);
export type SiteHealth = z.infer<typeof SiteHealth>;
export const CircuitState = z.enum(["closed", "open", "half_open"]);
export type CircuitState = z.infer<typeof CircuitState>;
/** 站点级限流(快照、实时搜索、浏览器共享) */
export const SiteRateLimit = z.object({
requestsPerMinute: z.number().int().positive().optional(),
minIntervalMs: z.number().int().nonnegative().optional(),
concurrency: z.number().int().positive().optional(),
});
export type SiteRateLimit = z.infer<typeof SiteRateLimit>;
export const Site = EntityBase.extend({
key: z.string().regex(/^[a-z][a-z0-9_-]*$/),
name: z.string().min(1),
adapter: SiteAdapter,
baseUrl: z.url(),
enabled: z.boolean(),
rateLimit: SiteRateLimit,
config: JsonObject,
healthStatus: SiteHealth,
circuitState: CircuitState,
circuitOpenUntil: Timestamp.nullable(),
snapshotEnabled: z.boolean(),
snapshotIntervalSec: z.number().int().positive().nullable(),
lastSnapshotAt: Timestamp.nullable(),
snapshotCursor: JsonObject,
});
export type Site = z.infer<typeof Site>;
export const SiteCreate = Site.pick({
key: true,
name: true,
adapter: true,
baseUrl: true,
enabled: true,
rateLimit: true,
config: true,
snapshotEnabled: true,
snapshotIntervalSec: true,
}).partial({ enabled: true, rateLimit: true, config: true, snapshotEnabled: true, snapshotIntervalSec: true });
export type SiteCreate = z.infer<typeof SiteCreate>;
export const SiteUpdate = SiteCreate.omit({ key: true }).partial();
export type SiteUpdate = z.infer<typeof SiteUpdate>;
export const SiteAuthKind = z.enum(["cookie", "passkey", "api_key", "password"]);
export type SiteAuthKind = z.infer<typeof SiteAuthKind>;
export const SiteAccountStatus = z.enum(["unknown", "active", "invalid", "expired"]);
export type SiteAccountStatus = z.infer<typeof SiteAccountStatus>;
export const SiteAccount = EntityBase.extend({
siteId: Id,
label: z.string().min(1),
authKind: SiteAuthKind,
secretRef: Id.nullable(),
enabled: z.boolean(),
status: SiteAccountStatus,
lastVerifiedAt: Timestamp.nullable(),
userInfo: JsonObject,
});
export type SiteAccount = z.infer<typeof SiteAccount>;
export const SiteAccountCreate = SiteAccount.pick({ siteId: true, label: true, authKind: true, enabled: true })
.partial({ enabled: true })
.extend({ credential: z.string().min(1).max(16384) });
export type SiteAccountCreate = z.infer<typeof SiteAccountCreate>;
export const SiteAccountUpdate = SiteAccountCreate.omit({ siteId: true }).partial();
export type SiteAccountUpdate = z.infer<typeof SiteAccountUpdate>;
export const FeedPurpose = z.enum(["movie", "series", "variety", "promotion", "search", "generic"]);
export type FeedPurpose = z.infer<typeof FeedPurpose>;
export const FeedKind = z.enum(["rss", "atom", "list", "query"]);
export type FeedKind = z.infer<typeof FeedKind>;
export const FeedHealth = z.enum(["unknown", "healthy", "degraded", "failing", "circuit_open", "disabled"]);
export type FeedHealth = z.infer<typeof FeedHealth>;
export const FeedSource = EntityBase.extend({
siteId: Id.nullable(),
siteAccountId: Id.nullable(),
name: z.string().min(1),
purpose: FeedPurpose,
kind: FeedKind,
urlSecretRef: Id.nullable(),
/** 脱敏 URL(passkey 等参数替换为 [REDACTED]) */
urlDisplay: z.string(),
parserVersion: z.string(),
enabled: z.boolean(),
pollIntervalSec: z.number().int().min(60),
filter: JsonObject,
cursor: JsonObject,
etag: z.string().nullable(),
lastModified: z.string().nullable(),
lastFetchAt: Timestamp.nullable(),
lastSuccessAt: Timestamp.nullable(),
nextFetchAt: Timestamp.nullable(),
consecutiveFailures: NonNegInt,
backoffUntil: Timestamp.nullable(),
healthStatus: FeedHealth,
lastError: JsonObject.nullable(),
});
export type FeedSource = z.infer<typeof FeedSource>;
/** url 为只写明文(可能含 passkey),服务端加密存为 secret 并生成 urlDisplay */
export const FeedSourceCreate = FeedSource.pick({
siteId: true,
siteAccountId: true,
name: true,
purpose: true,
kind: true,
enabled: true,
pollIntervalSec: true,
filter: true,
})
.partial({
siteId: true,
siteAccountId: true,
purpose: true,
kind: true,
enabled: true,
pollIntervalSec: true,
filter: true,
})
.extend({ url: z.url() });
export type FeedSourceCreate = z.infer<typeof FeedSourceCreate>;
export const FeedSourceUpdate = FeedSourceCreate.partial();
export type FeedSourceUpdate = z.infer<typeof FeedSourceUpdate>;
export const ResolutionStatus = z.enum(["unresolved", "resolved", "ambiguous", "unidentifiable", "rejected"]);
export type ResolutionStatus = z.infer<typeof ResolutionStatus>;
export const ReleaseAvailability = z.enum(["active", "site_disabled", "expired", "unknown"]);
export type ReleaseAvailability = z.infer<typeof ReleaseAvailability>;
/** 促销状态必须带观察时间与过期时间,下单前按策略复核(REQ §4.3) */
export const Promotion = z.object({
kind: z.string().optional(),
observedAt: Timestamp.optional(),
expiresAt: Timestamp.nullable().optional(),
raw: z.unknown().optional(),
});
export type Promotion = z.infer<typeof Promotion>;
export const TorrentFileEntry = z.object({
path: z.string(),
size: NonNegInt,
});
export type TorrentFileEntry = z.infer<typeof TorrentFileEntry>;
export const SourceRelease = EntityBase.extend({
siteId: Id,
siteReleaseId: z.string().min(1),
title: z.string(),
subtitle: z.string().nullable(),
categoryRaw: z.string().nullable(),
tagsRaw: z.array(z.string()),
sizeBytes: NonNegInt.nullable(),
infohashV1: z.string().nullable(),
infohashV2: z.string().nullable(),
fileList: z.array(TorrentFileEntry).nullable(),
promotion: Promotion,
promotionExpiresAt: Timestamp.nullable(),
seeders: z.number().int().nullable(),
leechers: z.number().int().nullable(),
/** 站点发布时间;区别于 firstSeenAt(本系统首次发现) */
sourcePublishedAt: Timestamp.nullable(),
firstSeenAt: Timestamp,
lastSeenAt: Timestamp,
detailFetchedAt: Timestamp.nullable(),
/** 标题解析结果(标题/年份/季集/分辨率/来源/编码/发布组…) */
parsed: JsonObject,
parserVersion: z.string().nullable(),
mediaEntityId: Id.nullable(),
contentUnitId: Id.nullable(),
resolutionStatus: ResolutionStatus,
resolutionEvidence: JsonObject,
availability: ReleaseAvailability,
raw: JsonObject,
});
export type SourceRelease = z.infer<typeof SourceRelease>;
/** 由适配器/任务写入(按 siteId+siteReleaseId upsert) */
export const SourceReleaseCreate = SourceRelease.omit({
...SERVER_MANAGED,
firstSeenAt: true,
lastSeenAt: true,
}).partial({
subtitle: true,
categoryRaw: true,
tagsRaw: true,
sizeBytes: true,
infohashV1: true,
infohashV2: true,
fileList: true,
promotion: true,
promotionExpiresAt: true,
seeders: true,
leechers: true,
sourcePublishedAt: true,
detailFetchedAt: true,
parsed: true,
parserVersion: true,
mediaEntityId: true,
contentUnitId: true,
resolutionStatus: true,
resolutionEvidence: true,
availability: true,
raw: true,
});
export type SourceReleaseCreate = z.infer<typeof SourceReleaseCreate>;
export const SourceReleaseUpdate = SourceReleaseCreate.omit({ siteId: true, siteReleaseId: true }).partial();
export type SourceReleaseUpdate = z.infer<typeof SourceReleaseUpdate>;
export const ObservationStatus = z.enum(["new", "linked", "ignored", "error"]);
export type ObservationStatus = z.infer<typeof ObservationStatus>;
export const Observation = EntityBase.extend({
feedId: Id,
guid: z.string().min(1),
contentDigest: z.string().min(1),
sourceReleaseId: Id.nullable(),
title: z.string(),
/** 已脱敏的链接;原始下载链接不对外 */
linkRedacted: z.string().nullable(),
raw: JsonObject,
sourcePublishedAt: Timestamp.nullable(),
fetchedAt: Timestamp,
firstSeenAt: Timestamp,
lastSeenAt: Timestamp,
status: ObservationStatus,
resolveAttempts: NonNegInt,
nextResolveAt: Timestamp.nullable(),
lastError: JsonObject.nullable(),
});
export type Observation = z.infer<typeof Observation>;
export const ObservationCreate = Observation.pick({
feedId: true,
guid: true,
contentDigest: true,
sourceReleaseId: true,
title: true,
linkRedacted: true,
raw: true,
sourcePublishedAt: true,
fetchedAt: true,
}).partial({ sourceReleaseId: true, linkRedacted: true, raw: true, sourcePublishedAt: true, fetchedAt: true });
export type ObservationCreate = z.infer<typeof ObservationCreate>;
export const ObservationUpdate = Observation.pick({
contentDigest: true,
sourceReleaseId: true,
title: true,
raw: true,
status: true,
resolveAttempts: true,
nextResolveAt: true,
lastError: true,
}).partial();
export type ObservationUpdate = z.infer<typeof ObservationUpdate>;
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "@spike/x",
"version": "0.0.0",
"exports": { ".": "./src/mod.ts" }
}
+20
View File
@@ -0,0 +1,20 @@
export const REDACTED = "[REDACTED]";
const SENSITIVE_KEY = /passkey|token|cookie|authorization|password|secret|api[-_]?key/i;
const SENSITIVE_QUERY = /([?&](?:passkey|token|access_token|api_key|apikey|auth|sign)=)[^&#\s"'<>]*/gi;
export function redactString(s: string): string {
return s.replace(SENSITIVE_QUERY, `$1${REDACTED}`);
}
export function redact(value: unknown, depth = 0): unknown {
if (depth > 8) return value;
if (typeof value === "string") return redactString(value);
if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1));
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (SENSITIVE_KEY.test(k) && typeof v !== "number" && v != null) out[k] = REDACTED;
else out[k] = redact(v, depth + 1);
}
return out;
}
return value;
}
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"resolveJsonModule": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": false,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"],
"paths": { "@spike/x": ["./packages/x/src/mod.ts"] }
},
"include": ["types/deno.d.ts", "packages/x/src", "apps/server/src", "apps/server/test/node-apis.test.ts", "apps/server/vitest.config.ts"]
}
+3
View File
@@ -0,0 +1,3 @@
# @umt/web
React Router 7 SPA 控制台(`ssr:false`),沿用 uhd_web 设计系统(token 见 `app/app.css`,规范见 `docs/design/`)。只通过 `/api/v1` 与 server 通信,请求/响应类型一律来自 `@umt/contracts`,不手写重复 DTO。开发端口 5180,`/api` 代理到 http://localhost:4380。
+13
View File
@@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import type { HealthStatus } from "@umt/contracts";
import { apiData, TTL_SECOND } from "~/lib/api";
/** 服务健康状态,15 秒轮询 */
export function useHealth() {
return useQuery({
queryKey: ["health"],
queryFn: ({ signal }) => apiData<HealthStatus>("/api/v1/health", { signal }),
refetchInterval: 15 * TTL_SECOND,
retry: false,
});
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,80 @@
import { KeyRound } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { clearAuth, getToken, setToken } from "~/lib/api";
/** 设置本机使用的 API Key(存 localStorage,首版单管理员) */
export function ApiKeyDialog() {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const hasKey = typeof window !== "undefined" && !!getToken();
return (
<Dialog
open={open}
onOpenChange={(o) => {
setOpen(o);
if (o) setValue("");
}}
>
<DialogTrigger asChild>
<Button
variant="outline"
size="icon-sm"
aria-label="设置 API Key"
title={hasKey ? "已设置 API Key" : "设置 API Key"}
>
<KeyRound className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>API Key</DialogTitle>
<DialogDescription>控制台用它访问服务端接口,只保存在本浏览器。</DialogDescription>
</DialogHeader>
<Input
type="password"
autoComplete="off"
placeholder={hasKey ? "已设置,输入新值以替换" : "粘贴 API Key"}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<DialogFooter>
{hasKey ? (
<Button
variant="ghost"
onClick={() => {
clearAuth();
setOpen(false);
toast.success("已清除 API Key");
}}
>
清除
</Button>
) : null}
<Button
disabled={!value.trim()}
onClick={() => {
setToken(value.trim());
setOpen(false);
toast.success("已保存 API Key");
}}
>
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,35 @@
import type { LucideIcon } from "lucide-react";
import { Inbox } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "~/lib/utils";
/** 空态:图标 + 标题 + 说明 + 可选操作。用于"尚无数据"与"功能建设中"占位 */
export function EmptyState({
icon: Icon = Inbox,
title,
description,
action,
plain = false,
className,
}: {
icon?: LucideIcon;
title: string;
description?: ReactNode;
action?: ReactNode;
/** 放在已有卡片内时用 plain,避免玻璃叠玻璃 */
plain?: boolean;
className?: string;
}) {
return (
<div
className={cn("pg-slab flex flex-col items-center justify-center rounded-xl px-6 py-14 text-center", className)}
>
<span className="mb-4 grid h-11 w-11 place-items-center rounded-full bg-foreground/[0.05] text-muted-foreground">
<Icon className="h-5 w-5" />
</span>
<p className="text-lead font-medium text-foreground">{title}</p>
{description ? <p className="mt-1.5 max-w-md text-caption text-muted-foreground">{description}</p> : null}
{action ? <div className="mt-5">{action}</div> : null}
</div>
);
}
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { findNavItem, NAV_ITEMS } from "./nav";
describe("控制台导航", () => {
it("12 个工作区,路径唯一", () => {
expect(NAV_ITEMS).toHaveLength(12);
expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(12);
});
it("按最长前缀匹配当前项", () => {
expect(findNavItem("/")?.label).toBe("首页");
expect(findNavItem("/library/abc")?.label).toBe("媒体库");
expect(findNavItem("/nope")).toBeUndefined();
});
});
@@ -0,0 +1,86 @@
import {
Bot,
CalendarClock,
Download,
FileStack,
Fingerprint,
Globe,
House,
Inbox,
Library,
type LucideIcon,
Rss,
Settings,
Tags,
} from "lucide-react";
export type NavItem = { to: string; label: string; icon: LucideIcon; description: string };
export type NavGroup = { title: string; items: NavItem[] };
/** 控制台 12 个工作区(任务书 §3.1 第 10 条 / REQ §13.1) */
export const NAV_GROUPS: NavGroup[] = [
{
title: "概览",
items: [
{
to: "/",
label: "首页",
icon: House,
description: "自动化概览、当前工作、需要我处理、缓存与保留、来源健康、最近产出",
},
{ to: "/inbox", label: "待处理中心", icon: Inbox, description: "按作品与根因合并的待决定事项,Agent 已先行调查" },
],
},
{
title: "媒体",
items: [
{ to: "/library", label: "媒体库", icon: Library, description: "作品、内容单元、版本目标与实际路径" },
{ to: "/identity", label: "身份与元数据", icon: Fingerprint, description: "外部身份映射、待识别池与元数据来源" },
{ to: "/organize", label: "整理记录", icon: FileStack, description: "Copy/Move 入库前后路径与逐文件结果" },
],
},
{
title: "获取",
items: [
{
to: "/subscriptions",
label: "订阅与收录规则",
icon: CalendarClock,
description: "单次收录与持续追踪,版本目标与预算",
},
{
to: "/preferences",
label: "偏好标签",
icon: Tags,
description: "软偏好与硬规则,手动取消的偏好不会被自动加回",
},
{ to: "/sites", label: "PT 站点工作台", icon: Globe, description: "统一格式浏览、搜索站点资源并关联本地库存" },
{ to: "/sources", label: "来源 / Feed / 快照", icon: Rss, description: "RSS 与站点快照的抓取状态、新鲜度与健康" },
{ to: "/tasks", label: "下载与任务", icon: Download, description: "下载、整理、核验的业务时间线与子任务" },
],
},
{
title: "助手与系统",
items: [
{ to: "/agent", label: "Agent 会话", icon: Bot, description: "用自然语言交代目标,Agent 在授权内执行并汇报" },
{
to: "/settings",
label: "系统设置",
icon: Settings,
description: "下载器与媒体服务器实例、路径映射、API Key、Pi 上游与自动化开关",
},
],
},
];
export const NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items);
/** 按路径找当前导航项(最长前缀匹配) */
export function findNavItem(pathname: string): NavItem | undefined {
return (
NAV_ITEMS.find((i) => i.to === pathname) ??
NAV_ITEMS.filter((i) => i.to !== "/" && pathname.startsWith(`${i.to}/`)).sort(
(a, b) => b.to.length - a.to.length,
)[0]
);
}
@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import { cn } from "~/lib/utils";
/** 控制台页面标题:标题 + 一句说明 + 右侧操作区 */
export function PageHeader({
title,
description,
actions,
className,
}: {
title: string;
description?: ReactNode;
actions?: ReactNode;
className?: string;
}) {
return (
<div className={cn("mb-6 flex flex-wrap items-end justify-between gap-4", className)}>
<div className="min-w-0">
<h1 className="text-title-lg font-semibold tracking-tight text-foreground">{title}</h1>
{description ? <p className="mt-1 text-body text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
);
}
@@ -0,0 +1,37 @@
import type { ReactNode } from "react";
import { cn } from "~/lib/utils";
export type StatusTone = "success" | "warning" | "destructive" | "neutral";
// 状态色只表示状态,不做装饰(docs/design/interface-materials.md)
const TONE: Record<StatusTone, { box: string; dot: string }> = {
success: { box: "border-success/30 bg-success/10 text-success", dot: "bg-success" },
warning: { box: "border-warning/30 bg-warning/10 text-warning", dot: "bg-warning" },
destructive: { box: "border-destructive/30 bg-destructive/10 text-destructive", dot: "bg-destructive" },
neutral: { box: "border-border bg-foreground/[0.04] text-muted-foreground", dot: "bg-muted-foreground" },
};
/** 状态徽章:文字必填,不只靠颜色传达状态 */
export function StatusBadge({
tone = "neutral",
children,
className,
}: {
tone?: StatusTone;
children: ReactNode;
className?: string;
}) {
const t = TONE[tone];
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-meta font-medium whitespace-nowrap",
t.box,
className,
)}
>
<span aria-hidden className={cn("h-1.5 w-1.5 rounded-full", t.dot)} />
{children}
</span>
);
}
@@ -0,0 +1,21 @@
import { Construction } from "lucide-react";
import { useLocation } from "react-router";
import { EmptyState } from "./empty-state";
import { findNavItem } from "./nav";
import { PageHeader } from "./page-header";
/** 工作区占位页:标题与说明取自导航表,正文为"建设中"空态 */
export function WorkspacePlaceholder({ milestone = "W3" }: { milestone?: string }) {
const { pathname } = useLocation();
const item = findNavItem(pathname);
return (
<>
<PageHeader title={item?.label ?? "未命名页面"} description={item?.description} />
<EmptyState
icon={item?.icon ?? Construction}
title="这个工作区还在建设中"
description={`计划在 ${milestone} 接入真实数据。`}
/>
</>
);
}
@@ -0,0 +1,10 @@
import { cn } from "~/lib/utils";
/**
* 全站背景上左上角那束淡淡的棱镜漏光(样式见 app.css 的 .prism-leak)。
* 各页面外壳各放一个:用户中心、流媒体浏览页、公共页(落地页除外)、测速、404、退订。
* 放在背景层之后、内容之前;宿主是层叠上下文时用 -z-10 垫在内容下面。
*/
export default function PrismLeak({ className }: { className?: string }) {
return <div aria-hidden className={cn("prism-leak", className)} />;
}
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more