Files
UHD_Media_Tool/docs/adr/ADR-004-deno-runtime.md
T
rootandClaude Opus 5.5 003815591c feat(agent): 运行时从 pi-agent-core 0.99.2 迁到 pi-durable 1.1.0 + pi-ai 1.1.0
- PiAgentRuntime 改用 pi-durable Harness:每个 run 一个 JSONL 存储目录 sessionDir/<runId>/ + root 对话;
  系统提示 / 能力工具 / 白名单与预算钩子装在每次运行自己的扩展里;watchEvents 翻译成 AgentRunEvent
- 挂起(工具内 close)立即封口,调用保持未完成,重开后 resume 重放;replayGate 用调用级 memo
- 旧格式 <runId>.jsonl 打开时抛 LegacySessionError;skills 加载与 <skill> 块格式内置(源自 pi-agent-core,MIT)
- tool_call_end 新增可选 durationMs;用量取 pi.usage 合计(整段会话总量)
- Azure provider 改名 azure,迁移 0093 改存量;移除 pi-agent-core,锁 pi-durable/pi-ai/chord 1.1.0
- 文档:ADR-003、pi-providers、runbook、agent README、CHANGELOG

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 13:42:36 +08:00

15 KiB
Raw Blame History

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;验证记录 spike deno-2.9.7(已删除,见 git ceb66f8);代码 spikes/deno/(已删除,见 git ceb66f8)

背景

用户要求运行时改成 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,示意如下,版本照搬现有锁定:

{
  "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]/",
    // 2026-10-08 起 pi-agent-core 换成 @earendil-works/[email protected] + @earendil-works/[email protected](见 ADR-003)
    "@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:

{ "name": "@umt/<x>", "version": "0.0.0", "exports": { ".": "./src/index.ts" } }

packages/<x>/package.json 精简为以下内容,去掉 dependencies、scripts、workspace:*:

{ "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 已构建并验证)

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

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 的实现)不用改。

补充(2026-10-02):维持 Deno,不迁移

讨论过换 Bun / Node 24,结论是不换。

  • 实际权限:生产编译参数为 --allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,cat,/app/umt-server,只有 --allow-run 白名单真正起限制,上文"读路径白名单"没有落实;选 Deno 的理由以直接跑 TS、单二进制、集中的 workspace 配置为主。
  • 已知摩擦都有固定做法:web 测试用 Node 跑 vitest、编译产物自检兜底漏嵌、deno.lock 被常驻进程改写(见 docs/plan/lessons.md)。