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:
1 parent
fe3613ee8f
commit
cf85481d95
147 files changed
+15213
No files matched your search
@@ -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 的实现)**不用改**。
|
||||
@@ -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` 重建。
|
||||
@@ -0,0 +1,6 @@
|
||||
**/node_modules
|
||||
dist
|
||||
logs
|
||||
web/build
|
||||
web/.react-router
|
||||
apps/server/src/pi/.sessions
|
||||
@@ -0,0 +1,2 @@
|
||||
types/deno.d.ts
|
||||
apps/server/src/pi/.sessions/
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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]" });
|
||||
});
|
||||
});
|
||||
@@ -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"] },
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+5200
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
listening 5199
|
||||
shutdown
|
||||
server finished
|
||||
@@ -0,0 +1,3 @@
|
||||
listening 5199
|
||||
shutdown
|
||||
server finished
|
||||
@@ -0,0 +1,3 @@
|
||||
listening 5199
|
||||
shutdown
|
||||
server finished
|
||||
@@ -0,0 +1,2 @@
|
||||
CLOSE_SSE=0 server exit after .210191521s alive=no
|
||||
CLOSE_SSE=1 server exit after .208894206s alive=no
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]"}}
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
{"count":40}
|
||||
{"total":"40","runs":"40","a":"24","b":"16"}
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
biz.side_effect_ledger
|
||||
openworkflow.openworkflow_migrations
|
||||
openworkflow.step_attempts
|
||||
openworkflow.workflow_runs
|
||||
openworkflow.workflow_signals
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]"
|
||||
@@ -0,0 +1,3 @@
|
||||
import { a } from "./sloppy";
|
||||
export const b = a;
|
||||
export * from "./a";
|
||||
@@ -0,0 +1,4 @@
|
||||
已生成 app/lib/design-tokens.generated.ts(字号 36 / 缓动 5 / 圆角 10 / 时长 6)
|
||||
design token 登记表是最新的(字号 36 / 缓动 5 / 圆角 10 / 时长 6)
|
||||
check exit=0
|
||||
设计 token 检查通过
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]"}}
|
||||
@@ -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)
|
||||
@@ -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%)
|
||||
|
||||
@@ -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%)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@spike/x",
|
||||
"version": "0.0.0",
|
||||
"exports": { ".": "./src/mod.ts" }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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。
|
||||
@@ -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
Reference in new issue
Block a user