- 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
15 KiB
15 KiB
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(已删除,见 gitceb66f8);代码spikes/deno/(已删除,见 gitceb66f8)
背景
用户要求运行时改成 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 的测试和类型检查也都跑过。
决定
- 运行时:Deno 2.9.7,本机、CI、镜像三处锁同一版本。镜像用
denoland/deno:2.9.7,已确认有 amd64 和 arm64。依赖统一用deno install+deno.lock管理,彻底去掉 pnpm。 - 语言版本: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。 - 依赖目录
nodeModulesDir: "auto"。 Vite、React Router、vitest、tsc 7 都要从 node_modules 解析依赖,所以必须保留这个目录。 - 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里登记一次,相当于统一目录。 - 不改动的部分: 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。 - 生产形态:单容器,一个
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、esbuilddevDependency。.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.tstasks.start:deno run --allow-net --allow-env --allow-sys --allow-read --env-file=../../.env src/main.tstasks.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)。