- 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
184 lines
15 KiB
Markdown
184 lines
15 KiB
Markdown
# 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`,示意如下,版本照搬现有锁定:
|
||
|
||
```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]/",
|
||
// 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`:
|
||
|
||
```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 的实现)**不用改**。
|
||
|
||
## 补充(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`)。
|