@sema-agent/settings-schema 11.1.0 → 11.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -0
- package/README.md +12 -0
- package/bin/settings-schema.mjs +5 -9
- package/conformance/VERSION +1 -1
- package/dist/atomic-write.d.ts +1 -1
- package/dist/atomic-write.js +35 -5
- package/dist/cli.d.ts +31 -6
- package/dist/cli.js +39 -131
- package/dist/config-dir-io.d.ts +2 -0
- package/dist/config-dir-io.js +13 -0
- package/dist/config-dir-migration.d.ts +64 -0
- package/dist/config-dir-migration.js +167 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node.d.ts +2 -0
- package/dist/node.js +3 -0
- package/dist/one-time-migrations.js +2 -1
- package/dist/previous-web-admin-env.d.ts +15 -0
- package/dist/previous-web-admin-env.js +20 -0
- package/dist/retired-envs.js +13 -2
- package/dist/retired-form.d.ts +6 -1
- package/dist/retired-form.js +10 -2
- package/dist/runtime-lift-targets.d.ts +7 -0
- package/dist/runtime-lift-targets.js +7 -0
- package/dist/shell-quote.d.ts +1 -0
- package/dist/shell-quote.js +14 -0
- package/dist/types.js +30 -9
- package/dist/web-admin-env.d.ts +2 -1
- package/dist/web-admin-env.js +2 -1
- package/dist/web-admin-session-url.d.ts +17 -0
- package/dist/web-admin-session-url.js +64 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,99 @@
|
|
|
6
6
|
> `@sema-agent/registry-core`(更早还曾是 `@sema-ai/registry-core`)。本档 `0.19.0` 以下各节里的
|
|
7
7
|
> 包名是**历史记录**,如实保留不追改。
|
|
8
8
|
|
|
9
|
+
## 11.2.0 — 2026-10-10 — 六小件:SEMA_REGISTRY_* 八名加回退役墓碑(换名窗期与保留表交集写明)· 断言令牌 / 凭证文件键进保留表 · 会话网页链接模板函数与配对解析 · CLI 命令路径 shell 引用 · 原子写器保留符号链接 · node 侧目录迁移函数导出(minor:新导出与新墓碑行,零删零改名)
|
|
10
|
+
|
|
11
|
+
> 本节六件:上一代连接名 `SEMA_REGISTRY_*` 八名进墓碑表(硬破坏,读侧同代要求)· 断言令牌两键进保留 env 名表 · 会话网页链接模板函数与配对解析 · 命令行印出的命令路径加 shell 引用 ·
|
|
12
|
+
> 原子写器遇符号链接保留链接 · 目录形迁移函数导出。新导出:主桶 3 个(会话链接)、`./node` 6 个(目录迁移),其余零新导出;唯一的硬破坏是第一件。
|
|
13
|
+
|
|
14
|
+
### 硬破坏:上一代 web-admin 连接名 `SEMA_REGISTRY_*` 八名进墓碑表(读侧同代要求)
|
|
15
|
+
|
|
16
|
+
`RETIRED_ENVS` 加八行 `renamed` 墓碑:`SEMA_REGISTRY_URL` / `_TOKEN` / `_TOKEN_FILE` / `_WORKER` / `_LIVE_URL` / `_WEB_URL` / `_DRY_RUN` / `_ADMIN_TOKEN`,去向各是 `WEB_ADMIN_ENV` 同键的现名
|
|
17
|
+
(`SEMA_WEB_ADMIN_*`,一跳)。进表的判据没变 —— 没有任何受支持的引擎还读这个名:11.0.0 把现名换进 `WEB_ADMIN_ENV` 时,在跑的引擎还把这八个名当活旋钮读,所以当时不登记;
|
|
18
|
+
引擎换读现名之后的这一版加回。旧名在场即拒启(替代名设没设都一样,空串 = 未设),`WorkerSpec` 的 `env` / `secretEnv` 写闸同拒(拒句就是墓碑句 `retiredEnvRefusal`),
|
|
19
|
+
读侧 `readWorkersRows` 把带着它们的存量行逐行扣下,目录 wire 的 `retired[]` 多这八行(`buildRetiredEnvWire()` 同表派生,零代码改动)。上一代名字面只在包内私有的一处写一次,
|
|
20
|
+
墓碑行与保留表都从那里取,去向从 `WEB_ADMIN_ENV` 取、不手抄;包根不多导出任何名字。
|
|
21
|
+
|
|
22
|
+
- **读侧同代要求**:server ≥ 7.124.0 起才拒旧名;更老的 server 仍把它们当活旋钮读。给 worker 渲染 env 的一方(web-admin 渲染器)按**目标引擎的代**写名 ——
|
|
23
|
+
目标引擎 ≥ 7.124.0 写 `SEMA_WEB_ADMIN_*`,只有还读上一代名的引擎才写上一代名(混版本车队按目标版本渲)。
|
|
24
|
+
- **谁受影响**:部署里仍设着 `SEMA_REGISTRY_*` 的运维(server ≥ 7.124.0 拒启);在 worker 规格的 `env` / `secretEnv` 里自写其中任一名的(写闸拒;11.0.0 时这八名里只有 URL / WORKER / TOKEN 三名被拒,
|
|
25
|
+
其余五名照收,现在八名都拒);存量里带着它们的 worker 行(读侧扣下、在列表里标红)。
|
|
26
|
+
- **怎么改**:把这八个名字改成同键的现名(`SEMA_WEB_ADMIN_URL` 等);worker 规格里自写的删掉(渲染器会写)。
|
|
27
|
+
- **不改会怎样**:server ≥ 7.124.0 拒启,拒句点名旧名与现名;存量 worker 行读侧被扣下。
|
|
28
|
+
|
|
29
|
+
**两张表答两个问题,交集是窗期形,终态是零交集**:
|
|
30
|
+
- 保留表 `RESERVED_WORKER_ENV` =「规格作者可不可以自己设这个名」(不可以:渲染器 / 编排器写它,或某个结构字段产出它,或它与渲染器 / 编排器写的名互斥);
|
|
31
|
+
- 墓碑表 `RETIRED_ENVS` =「带着这张表的引擎还读不读这个名」(≥ 7.124.0 的引擎不读、见之拒启)。≤ 7.123.0 的引擎内带的是 10.1.1,看不见本版的表、仍读上一代名 ——
|
|
32
|
+
所以墓碑的进表判据读作「没有带着本表的引擎还读这个名」。
|
|
33
|
+
- **窗期**:渲染器对 ≤ 7.123.0 的目标引擎仍写上一代的 `URL` / `WORKER` / `TOKEN`(`TOKEN_FILE` 与 `TOKEN` 互斥,同组同入),这四名既是保留名也是墓碑,写闸交两句(墓碑句在前、保留句在后,
|
|
34
|
+
保留句与现名同一结构位);另外四名(`LIVE_URL` / `WEB_URL` / `DRY_RUN` / `ADMIN_TOKEN`)没有谁写、也不与谁互斥,只是墓碑。窗期交集是个写死的闭集,有钉盯着。
|
|
35
|
+
- **窗关即终态**:web-admin 渲染器不再对任何目标引擎写上一代名之后(此后没有引擎要读它们),这四名离开保留表,两表回到**零交集** —— 11.0.0 记的「老三名搬去墓碑表」就是这个终态,不是本版。
|
|
36
|
+
那一刻窗期交集那一钉翻回零交集,保留名增词账里记删词。
|
|
37
|
+
|
|
38
|
+
### 行为变:断言令牌(`wat_`)的两个声明键与主凭证的文件键进保留 env 名表
|
|
39
|
+
|
|
40
|
+
`RESERVED_WORKER_ENV` 加三名:`SEMA_WEB_ADMIN_ASSERTION_TOKEN`(值键,编排器注入)、`SEMA_WEB_ADMIN_ASSERTION_TOKEN_FILE` 与 `SEMA_WEB_ADMIN_TOKEN_FILE`(两把凭证的文件键,只给不经编排器的手接引擎、由运维直设)。
|
|
41
|
+
文件键与同一份凭证的值键互斥(`judgeWebAdminCredentialDeclaration(env, "assertion" | "access")`:一对里两个都设 ⇒ 拒启),规格里写了任一个就与渲染器 / 编排器注入的值键撞 —— 主凭证与断言令牌是同一条规则,所以同一个答案。名字取自 `WEB_ADMIN_ENV`。
|
|
42
|
+
换名窗里上一代的 `SEMA_REGISTRY_TOKEN_FILE` 与老三名同组同入(见上一节)。`WorkerSpec` 的 `env` / `secretEnv` 写闸同拒,拒句点名各自的出路:值键 ⇒ 「由编排器注入」,文件键 ⇒ 「由运维直设,不经 worker 规格」
|
|
43
|
+
(句尾仍是 `set it via <出路>`);读侧 `readWorkersRows` 把带着它们的存量行逐行扣下。
|
|
44
|
+
|
|
45
|
+
- **谁受影响**:在 worker 规格的 `env` / `secretEnv` 里自写这三个名字的(此前照收;引擎起服时与注入的凭证撞而拒启)。
|
|
46
|
+
- **怎么改**:从规格里删掉;值由编排器注入,不经编排器的手接引擎由运维在引擎进程环境里直设。
|
|
47
|
+
- **不改会怎样**:写闸拒;存量行读侧被扣下、在列表里标红。
|
|
48
|
+
|
|
49
|
+
### 新增:会话网页链接的一只模板函数与它的配对解析(`webAdminSessionUrl` / `parseWebAdminSessionUrl`)
|
|
50
|
+
|
|
51
|
+
web-admin 里一个会话的网页链接此前由各处按地址形手拼(把别仓的路由抄成自己的字符串)。路由属于 web-admin,现形是 `<webBaseUrl>/chat?agent=<worker>&session=<sessionId>`
|
|
52
|
+
(聊天页只读这两个查询参数;只带 `session` 的链接会落到页面自己挑的 worker 上,会话不在那一台就落空)。新导出(主桶,纯函数、浏览器安全,additive):
|
|
53
|
+
|
|
54
|
+
- `webAdminSessionUrl(webBaseUrl, { worker, sessionId })` —— 两位必填(空 / 缺位 / 非串 ⇒ 抛 `TypeError`),逐位 `encodeURIComponent`(含 `&` `#` `=` `?` 的 id 不串位;孤立代理项 ⇒ `URIError`,不静默替换),
|
|
55
|
+
基址须是干净的绝对 http(s) URL(无 userinfo / query / fragment,可带路径前缀,尾斜杠折掉;判的是 `NON_SECRET_URL` 同一只形判官),返回绝对 URL。
|
|
56
|
+
- `parseWebAdminSessionUrl(link)` —— 配对解析,收绝对 / 相对串、`URL`、`URLSearchParams`;宽读(旧链接可能缺 `agent`):缺的或空的那一位不出现在结果里,重复参数取第一个,路径与别的参数不看;串不是 URL ⇒ 抛 `TypeError`。
|
|
57
|
+
- 型 `WebAdminSessionTarget`(`{ worker, sessionId }`)。
|
|
58
|
+
|
|
59
|
+
往返钉:任意含特殊字符的 (worker, sessionId) 经模板再经解析逐位还原。链接没有 scope 位;日后加 scope 时作可选第三位、同一函数。
|
|
60
|
+
- **谁需要**:要给用户一个会话链接的壳 / 客户端 / 服务端文档;web-admin 聊天页的深链读写改读配对解析(路径与参数名只此一份)。
|
|
61
|
+
- **谁受伤**:无(纯增)。已手拼 `…/chat?session=<id>` 的调用方到货改调(只带 `session` 的形今天就会落空)。
|
|
62
|
+
|
|
63
|
+
### 行为变:命令行印出的命令,路径按 shell 单引号规则引用
|
|
64
|
+
|
|
65
|
+
`migrate` 命令的 stderr 指路句(上一层目录里 `config.d` 在场时,「`<目录>/config.d` exists — run: …」)此前把路径原样接在命令后面:配置根路径含空格(或引号、`$`)的运维照原样粘贴,
|
|
66
|
+
shell 把路径拆成多个参数 ⇒ 用法行 rc 2。现在印出的命令里路径过一只 `shellQuote`(POSIX 单引号规则;只有 ASCII 字母数字与 `_ @ + : , . / -` 组成的串原样不加引号,其余整串单引号包起来,
|
|
67
|
+
串里的 `'` 写成 `'\''`;空串写成 `''`),指路句可以直接粘贴跑通。用法行与迁移指路句里的 `<config.d directory>` 是元变量,不是参数,原样不引用(这两处的句子逐字不变)。
|
|
68
|
+
命令的拼写收在一处(带占位的模板 / 带真路径的函数),别处不再各自拼。无新导出(`shellQuote` 包内私有)。
|
|
69
|
+
- **谁需要**:配置根路径含空格 / 特殊字符的运维与脚本。
|
|
70
|
+
- **谁受伤**:按字面比对该指路句的脚本 —— 路径含特殊字符时句子里的路径多了引号;路径在安全字符集内的句子逐字不变。
|
|
71
|
+
- **补偿**:无需;要路径本身,去掉引号即可(引用遵循 POSIX shell,Windows cmd.exe 不认单引号)。
|
|
72
|
+
|
|
73
|
+
### 行为变:写盘处遇符号链接 —— 保留链接、更新目标(悬空链接响亮拒)
|
|
74
|
+
|
|
75
|
+
`config.d` 里的域文件若是符号链接(运维用链接把 `config.d` 指到别处管理的部署),写盘处此前在链接所在目录建临时文件再 `rename` 盖到链接路径上:链接本身被换成普通文件、链接目标不动 ——
|
|
76
|
+
写一次后链接断,以后两份漂移。现在那一只原子写器(`writeDomainFile` / `writeRemoteExec` / 调度店 / 命令行迁移写器都调它)先把目标解析到真文件(`realpath`),临时文件建在**真文件所在目录**、
|
|
77
|
+
rename 到真文件:链接(及链上每一环)原样保留,真文件内容更新,文件模式规则作用在真文件上。临时文件与真文件同目录 ⇒ rename 不会跨文件系统;真文件所在目录写不进、或 rename 因别的原因失败 ⇒
|
|
78
|
+
响亮抛,**绝不**退回成「在链接位置写一个普通文件」(那是静默换形)。路径本身是悬空链接 ⇒ 响亮抛(消息点名这条链接与它指向的位置),既不在链接位置建普通文件,也不替链接建它指向的文件;成环的链接抛 `ELOOP`。
|
|
79
|
+
目标不存在 ⇒ 新建(经符号链接的目录写新文件时,文件落在真目录里)。
|
|
80
|
+
- **谁需要**:用符号链接管理 `config.d` 域文件的部署(迁移命令、`writeDomainFile` 写一次后链接不再被换掉)。
|
|
81
|
+
- **谁受伤**:(1) 域文件是悬空链接的 —— 此前写成功(并把链接换成普通文件),现在抛;(2) 链接指向的真文件所在目录对进程不可写的 —— 此前写进链接所在目录成功(链接被换形),现在抛。
|
|
82
|
+
- **补偿**:修好或删掉悬空链接;让进程对真文件所在目录可写,或去掉链接改用普通文件。
|
|
83
|
+
|
|
84
|
+
### 新增:目录形迁移函数导出(`migrateConfigDirectory` / `nodeConfigDirectoryIo`,`./node`)
|
|
85
|
+
|
|
86
|
+
「读目录 → 迁 → 整域终判 → 原子写 → 删退役域文件」这条语义此前只活在命令行里,没有覆盖面开关、没导出 —— 引擎起服对本地 `config.d` 的迁移只能按同语义自写一份(读法 / 写法 / 模式规则各一小段)。
|
|
87
|
+
现在语义只此一处:`./node` 导出 `migrateConfigDirectory(dir, { documents, io, check? })`,命令行成了它的渲染器(退出码与报告行),引擎调同一只函数。新导出(additive,`./node` 子路径):
|
|
88
|
+
`migrateConfigDirectory`、真 I/O 绑定 `nodeConfigDirectoryIo`(`node:fs` + 本包唯一的原子写器与它的模式表)、型 `ConfigDirectoryIo` / `ConfigDirectoryMigration` / `ConfigDirectoryRefusal` / `MigrateConfigDirectoryOptions`。
|
|
89
|
+
|
|
90
|
+
- **覆盖面 = 调用方读的域名集**(`documents`):覆盖面外的文件不读、不判、不写、不删,它们是坏文件也挡不住覆盖面内的迁移;拼错的名字抛 `TypeError`。命令行的覆盖面是全部配置域加退役域。
|
|
91
|
+
**迁移不越过覆盖面去写**:退役的 `runtime` 域迁出时要写 `governance` 与 `limits`,覆盖面没把这两个域一并包进来(只含 `runtime`,或只含其中一个)时,目录算不上「一个 scope 的全集」,
|
|
92
|
+
`runtime` 的迁移以收据 `refused` 拒迁、一字不写 —— 不会把覆盖面外已有的 `governance.json` / `limits.json` 当作「从未设过」覆盖掉;
|
|
93
|
+
日后若别的迁移步新建覆盖面外的文档,函数响亮抛(守卫),不静默写。要迁 `runtime`,覆盖面里放齐 `runtime`、`governance`、`limits`。
|
|
94
|
+
- **结果是一个闭集**(调用方穷举):`unreadable`(读不了目录 / 文件,或某份文档是坏文件;一字不写)· `no-documents`(覆盖面内一份文档都没有;带回目录清单,调用方自己判「填错了目录」还是「真空」)·
|
|
95
|
+
`refused`(迁移步拒,或要写回的文档整域读不进;一字不写)· `noop` · `pending`(`check: true` 且有改动;一字不写)· `applied`(带写了哪些、删了哪些;先写后删,中途断掉再跑幂等)。
|
|
96
|
+
写盘阶段的 I/O 错误原样抛出。
|
|
97
|
+
- **命令行行为逐字不变**:既有命令行钉全绿(退出码、stdout 两行、stderr 句子);并有一道钉盯着「命令行没有自己的语义」—— 对仓内一致性语料的**每个 case 目录**,`migrate` 与 `migrate --check`
|
|
98
|
+
经命令行和直接调 `migrateConfigDirectory` 的退出码 / stdout / stderr / 落盘字节逐字同(函数这一边的期望由一只独立的小渲染器算出;语料的结局分布也被钉住,五形都有样本),
|
|
99
|
+
外加真 bin × 真 I/O 绑定的磁盘端到端对拍;唯一有意的差别是上面「命令行印出的命令」那一条的路径引用。
|
|
100
|
+
- **谁需要**:对本地 `config.d` 做一次性迁移的进程(引擎起服)。**谁受伤**:无(纯增)。已自写同语义一份的调用方到货改调、删掉自写段。
|
|
101
|
+
|
|
9
102
|
## 11.1.0 — 2026-10-10 — 11.0.0 持久面三件收口:URL fragment 拒句补迁移指路 + 全称律源码钉 · 迁移命令对零域文档目录响亮拒 · 写盘处收成一只原子写器与模式表(minor:一个新导出,零删零改名)
|
|
10
103
|
|
|
11
104
|
> 本节三件,都是对 11.0.0 持久面的收口:拒句补指路、迁移命令填错目录响亮拒、写盘处收成一只。一个新导出(`NON_SECRET_URL_NO_MIGRATION`,additive),零删零改名。
|
package/README.md
CHANGED
|
@@ -74,6 +74,11 @@ therefore shows only `zod`. Node built-ins only (`node:crypto`, `node:fs`).
|
|
|
74
74
|
`--check` 有改动退出 3)。参数是 `config.d` 目录**本身**(里面直接放 `<域>.json`):目录里有条目却没有一份域文档 —— 填成了它的上一层、只有
|
|
75
75
|
`remote-exec.json` 之类非域文件 —— 退出 2、stdout 空,stderr 点名这个目录(`<目录>/config.d` 在场就直接点它);目录真空(零条目,或只有以 `.` 开头的条目)是合法态
|
|
76
76
|
(新部署的空 `config.d`),和「有域文档、只是没有要改的」一样是 noop、退出 0。
|
|
77
|
+
- **目录形的函数**(`./node`):`migrateConfigDirectory(dir, { documents, io, check? })` 是命令行背后的那只函数 —— 「读目录 → 迁 → 整域终判 → 原子写 → 删退役域文件」这条语义只此一处,
|
|
78
|
+
命令行只是它的渲染器(退出码 + 报告行),要对本地 `config.d` 做同一件事的进程(比如引擎起服)调它,不按同语义自写一份。`documents` = 调用方读的域名集(覆盖面:覆盖面外的文件不读、不判、不写、不删);
|
|
79
|
+
覆盖面要迁 `runtime` 就得把 `governance` 与 `limits` 一并放进来(迁出的旧值要写那两份,不能覆盖面外去写;缺了就按 `partial` 拒迁 `runtime`、一字不写)。
|
|
80
|
+
`io` 注入 `{ readDir, readFile, writeFileAtomic, removeFile }`,真绑定是 `nodeConfigDirectoryIo`(`node:fs` + 本包唯一的原子写器与它的模式表)。返回一个闭集
|
|
81
|
+
(`unreadable` / `no-documents` / `refused` / `noop` / `pending` / `applied`),调用方穷举;写盘阶段的 I/O 错误原样抛出(已写完的留在新位上,再跑一次幂等收尾)。
|
|
77
82
|
- **迁什么**(七条,`ONE_TIME_MIGRATIONS` 每行写着改写规则、删法、删后由谁接住):退役 `runtime` 域(治理三闸 → `governance`、五个限额键 → `limits`)·
|
|
78
83
|
hosts 超界 · 调度店 durable 键(读时改写,下一个写者落盘)· workers 6.x 行 · 模型钥匙双设 · `atModelAllowlist` → `atMention` · URL fragment。
|
|
79
84
|
- **什么时候删**:每条的窗 = 11.0.0 发布日 + 30 天,窗关之后的那个 major 整族删(删法与删后的响亮臂见登记表那一行);本包的到期门到点即红。
|
|
@@ -308,6 +313,13 @@ therefore shows only `zod`. Node built-ins only (`node:crypto`, `node:fs`).
|
|
|
308
313
|
no default) → `env` / `file` / `absent`, the one pick-exactly-one rule for every credential: an empty string is unset, BOTH set (non-empty) throws (the refusal
|
|
309
314
|
names the two keys, never a value), the file path comes back verbatim (resolving a relative one against a cwd
|
|
310
315
|
is the reader's job).
|
|
316
|
+
- **Session web link** (`web-admin-session-url.ts`, main barrel): `webAdminSessionUrl(webBaseUrl, { worker, sessionId })` — the ONE
|
|
317
|
+
template for a web-admin session link, `<webBaseUrl>/chat?agent=<worker>&session=<sessionId>`: both parts required (a link with only
|
|
318
|
+
the session id lands on whichever worker the page picks and may not find it), each part `encodeURIComponent`-escaped, the base a
|
|
319
|
+
clean absolute http(s) URL (trailing slashes folded; userinfo / query / fragment refused), the result absolute; bad input throws,
|
|
320
|
+
never a half link. `parseWebAdminSessionUrl(link)` is its paired reader (absolute / relative string, `URL` or `URLSearchParams`;
|
|
321
|
+
a missing or empty part is simply absent from the result) — the page and every link-minting caller share this one definition of the
|
|
322
|
+
route and the parameter names. No scope part today; a scope would be an optional third part of the same function.
|
|
311
323
|
- **Retired env names** (`retired-envs.ts`, main barrel): `RETIRED_ENVS` — the ONE tombstone table (literal tuples
|
|
312
324
|
`[old, to, "renamed"]` / `[old, to, "replaced", why]` / `[old, why, "removed"]`, a closed set of three families,
|
|
313
325
|
frozen). A tombstone is permanent: the old name present ⇒ refuse to start, whether or not the replacement is also
|
package/bin/settings-schema.mjs
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `settings-schema` — this package's command line (`migrate <config.d directory> [--check]`, the one-time migrations).
|
|
3
3
|
// The real-I/O injection point: the logic is `runSettingsSchemaCli` (dist/cli.js), which never touches the filesystem itself — this
|
|
4
|
-
// entry hands it
|
|
5
|
-
// is written and with what mode
|
|
6
|
-
|
|
4
|
+
// entry hands it the package's one directory-migration I/O binding (dist/config-dir-io.js: directory / file reads and removal, and
|
|
5
|
+
// writes through the one atomic writer, dist/atomic-write.js, which alone decides how a file is written and with what mode) plus the
|
|
6
|
+
// two output streams.
|
|
7
7
|
import { runSettingsSchemaCli } from "../dist/cli.js";
|
|
8
|
-
import {
|
|
8
|
+
import { nodeConfigDirectoryIo } from "../dist/config-dir-io.js";
|
|
9
9
|
|
|
10
10
|
const io = {
|
|
11
|
-
|
|
12
|
-
readFile: (path) => readFile(path, "utf8"),
|
|
13
|
-
// the files this command writes are config.d domain documents
|
|
14
|
-
writeFileAtomic: async (path, text) => writeFileAtomic(path, text, "config-document"),
|
|
15
|
-
removeFile: (path) => unlink(path),
|
|
11
|
+
...nodeConfigDirectoryIo,
|
|
16
12
|
stdout: (text) => process.stdout.write(text),
|
|
17
13
|
stderr: (text) => process.stderr.write(text),
|
|
18
14
|
};
|
package/conformance/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
11.
|
|
1
|
+
11.2.0
|
package/dist/atomic-write.d.ts
CHANGED
|
@@ -10,5 +10,5 @@ export declare const ATOMIC_FILE_CLASSES: {
|
|
|
10
10
|
};
|
|
11
11
|
};
|
|
12
12
|
export type AtomicFileClass = keyof typeof ATOMIC_FILE_CLASSES;
|
|
13
|
-
/** 原子写 `text`(UTF-8)到 `target
|
|
13
|
+
/** 原子写 `text`(UTF-8)到 `target`(符号链接则写到它指向的真文件,见文件头);模式按 `kind` 在 {@link ATOMIC_FILE_CLASSES} 里的行。父目录须已存在。 */
|
|
14
14
|
export declare function writeFileAtomic(target: string, text: string, kind: AtomicFileClass): void;
|
package/dist/atomic-write.js
CHANGED
|
@@ -11,23 +11,53 @@
|
|
|
11
11
|
* **已有的文件沿用它原来的模式**(运维 chmod 过的文件不被一次写回覆盖)。
|
|
12
12
|
* · `scheduler-store` —— 调度店耐久记录(可能带 principal / session 标识):新建 = 0o600,且**每次写都回到 0o600**(已有的更宽的文件被收回)。
|
|
13
13
|
* 新建的模式照常经进程 umask 遮一道(`writeFile` 的 `mode` 语义):比表更严的 umask 只会更严,不会被这里放宽;沿用的模式原样恢复(不再过 umask)。
|
|
14
|
+
*
|
|
15
|
+
* ## 目标是符号链接
|
|
16
|
+
* 域文件可以是符号链接 —— 运维用链接把 `config.d` 指到别处管理的部署,那是有意的布局,写器不替他改布局。规则一条:**先把目标解析到真文件(`realpath`),
|
|
17
|
+
* 临时文件建在真文件所在目录、rename 到真文件**:链接(及链上每一环)原样保留,真文件的内容更新,模式规则作用在真文件上。临时文件与目标同目录 ⇒ rename 不会跨文件系统;
|
|
18
|
+
* 真文件所在目录写不进、或 rename 因别的原因失败 ⇒ 响亮抛,**绝不**回退成「在链接位置写一个普通文件」(那是静默换形)。路径本身是悬空链接 ⇒ 响亮抛
|
|
19
|
+
* (不在链接位置建普通文件,也不替链接建它指向的文件);成环的链接由 `realpath` 抛 `ELOOP`。目标不存在 ⇒ 新建(没有链接可言;经符号链接的目录写新文件时,文件落在真目录里)。
|
|
14
20
|
*/
|
|
15
21
|
import { randomBytes } from "node:crypto";
|
|
16
|
-
import { chmodSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { chmodSync, lstatSync, readlinkSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
23
|
import path from "node:path";
|
|
18
24
|
/** 按文档类的模式表(闭集)。 */
|
|
19
25
|
export const ATOMIC_FILE_CLASSES = {
|
|
20
26
|
"config-document": { newMode: 0o644, existing: "keep" },
|
|
21
27
|
"scheduler-store": { newMode: 0o600, existing: "reset" },
|
|
22
28
|
};
|
|
23
|
-
/**
|
|
29
|
+
/** 写盘的真目标:`target` 是符号链接 ⇒ 解析到它指向的真文件(悬空 ⇒ 抛);不是链接或不存在 ⇒ 原样。 */
|
|
30
|
+
function resolveWriteTarget(target) {
|
|
31
|
+
let link;
|
|
32
|
+
try {
|
|
33
|
+
link = lstatSync(target).isSymbolicLink();
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
if (e.code === "ENOENT")
|
|
37
|
+
return target; // 不存在:新建
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
if (!link)
|
|
41
|
+
return target;
|
|
42
|
+
try {
|
|
43
|
+
return realpathSync(target);
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
if (e.code === "ENOENT") {
|
|
47
|
+
throw new Error(`writeFileAtomic: ${target} is a dangling symbolic link (it points at ${readlinkSync(target)}) — refusing to replace the link or to create what it points at; repair or remove the link`, { cause: e });
|
|
48
|
+
}
|
|
49
|
+
throw e;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** 原子写 `text`(UTF-8)到 `target`(符号链接则写到它指向的真文件,见文件头);模式按 `kind` 在 {@link ATOMIC_FILE_CLASSES} 里的行。父目录须已存在。 */
|
|
24
53
|
export function writeFileAtomic(target, text, kind) {
|
|
54
|
+
const real = resolveWriteTarget(target);
|
|
25
55
|
const spec = ATOMIC_FILE_CLASSES[kind];
|
|
26
56
|
let kept; // the existing file's mode, when this class keeps it
|
|
27
57
|
switch (spec.existing) {
|
|
28
58
|
case "keep":
|
|
29
59
|
try {
|
|
30
|
-
kept = statSync(
|
|
60
|
+
kept = statSync(real).mode & 0o777;
|
|
31
61
|
}
|
|
32
62
|
catch (e) {
|
|
33
63
|
if (e.code !== "ENOENT")
|
|
@@ -41,12 +71,12 @@ export function writeFileAtomic(target, text, kind) {
|
|
|
41
71
|
throw new Error(`writeFileAtomic: unhandled existing-file rule ${String(unhandled)}`);
|
|
42
72
|
}
|
|
43
73
|
}
|
|
44
|
-
const tmp = path.join(path.dirname(
|
|
74
|
+
const tmp = path.join(path.dirname(real), `.${path.basename(real)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
45
75
|
try {
|
|
46
76
|
writeFileSync(tmp, text, { encoding: "utf8", mode: kept ?? spec.newMode });
|
|
47
77
|
if (kept !== undefined)
|
|
48
78
|
chmodSync(tmp, kept); // a kept mode is restored exactly (the creation mode above went through the umask)
|
|
49
|
-
renameSync(tmp,
|
|
79
|
+
renameSync(tmp, real);
|
|
50
80
|
}
|
|
51
81
|
catch (e) {
|
|
52
82
|
try {
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,9 +1,34 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `settings-schema` 命令行的核心(11.0.0)—— 子命令只有一个:`migrate <config.d 目录> [--check]`,即一次性迁移
|
|
3
|
+
* `migrateStoredDocuments`(`./one-time-migrations`)的目录形(本地 config.d / 离线文件用;web-admin 在它起服时直接调同一只函数)。
|
|
4
|
+
* 目录形的语义(读目录 → 迁 → 整域终判 → 原子写 → 删 retired 域文件)只有一处实现:`migrateConfigDirectory`(`./config-dir-migration`,`./node` 导出,引擎起服调同一只);
|
|
5
|
+
* 本模块是它的**渲染器**:把它的结果闭集渲染成退出码、报告行与 stderr 句子。命令行的覆盖面是全部配置域加 retired 域。
|
|
6
|
+
* 目录被当作**一个 scope 的全集**(`coverage: "scope"`):目录里没有的域文件就是从未设过。
|
|
7
|
+
*
|
|
8
|
+
* 本模块不碰 `node:fs`:读写文件由入口 `bin/settings-schema.mjs` 注入(真绑定是 `./config-dir-io` 的 `nodeConfigDirectoryIo`,写盘用的是 `./atomic-write` 那一只原子写器 —— 写法与文件模式在那里一处定),所以测试可以直接驱动它。
|
|
9
|
+
* 不进主桶 —— 它是 bin 的实现,不是契约面。
|
|
10
|
+
*
|
|
11
|
+
* 读哪些文件:目录里 `<名>.json`,名是配置域或 retired 域(`runtime`)—— 便携与否不论(离线的 `workers.json` 一样迁);别的文件
|
|
12
|
+
* (`remote-exec.json`、隐藏文件、不认识的名)不读不动。
|
|
13
|
+
*
|
|
14
|
+
* 零域文档分两形:**有条目却没有域文档**(任何非 `.` 开头的条目 —— 子目录、说明文件、不认识的名)⇒ 退出 2;**目录真空**(零条目,或只有以 `.` 开头的条目)⇒ 照常走判定,
|
|
15
|
+
* 零收据 = noop 退出 0 —— 新部署的空 `config.d` 是合法态,「没有东西可迁」不是「指错目录」(搬空之后再跑同一目录也因此幂等)。
|
|
16
|
+
*
|
|
17
|
+
* 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 `migrateConfigDirectory`),**一字不写**;
|
|
18
|
+
* 2 = 用法 / 读目录 / 读文件错,或某个域文件是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
|
|
19
|
+
* (多半是填错了目录:要的是 `config.d` 目录本身;`<目录>/config.d` 在场时句子直接点它)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
|
|
20
|
+
* 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
|
|
21
|
+
*
|
|
22
|
+
* ## 报告形(让「没写」在机读上不可能被读成「写了」)
|
|
23
|
+
* 退出码 0 / 1 / 3 时 stdout 恰两行:第 1 行 = **唯一结论行** `outcome=<refused|applied|noop|pending> written=<true|false>`;第 2 行 = JSON 报告
|
|
24
|
+
* (`event: "stored_documents_migrated"`,逐条收据只点名坐标与处置、不带值),同带 `outcome` / `written`。`written` 由 `outcome` 派生
|
|
25
|
+
* (只有 `applied` 写盘)。**键名带时态**:`written=true` 才有 `applied`;没写(refused / noop / pending)时是 `wouldApply`。
|
|
26
|
+
* 结论行在**写盘成功之后**才出(写盘抛错 ⇒ 没有 `outcome=applied`)。写盘次序:先写改过 / 新建的文件,最后删被移除的 retired 域文件 ——
|
|
27
|
+
* 中途断掉再跑一次,迁移照样恒等收尾(已搬过去的值在新位上,旧文件里同名键按「新位赢」丢弃)。
|
|
28
|
+
*/
|
|
29
|
+
import { type ConfigDirectoryIo } from "./config-dir-migration.js";
|
|
30
|
+
/** 入口注入的 I/O(核心不碰 node:fs):目录迁移的四个口加 stdout / stderr。入口注入的写盘是 `./atomic-write` 的那一只(经 `./config-dir-io`)。 */
|
|
31
|
+
export interface SettingsSchemaCliIo extends ConfigDirectoryIo {
|
|
7
32
|
stdout(text: string): void;
|
|
8
33
|
stderr(text: string): void;
|
|
9
34
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `settings-schema` 命令行的核心(11.0.0)—— 子命令只有一个:`migrate <config.d 目录> [--check]`,即一次性迁移
|
|
3
3
|
* `migrateStoredDocuments`(`./one-time-migrations`)的目录形(本地 config.d / 离线文件用;web-admin 在它起服时直接调同一只函数)。
|
|
4
|
+
* 目录形的语义(读目录 → 迁 → 整域终判 → 原子写 → 删 retired 域文件)只有一处实现:`migrateConfigDirectory`(`./config-dir-migration`,`./node` 导出,引擎起服调同一只);
|
|
5
|
+
* 本模块是它的**渲染器**:把它的结果闭集渲染成退出码、报告行与 stderr 句子。命令行的覆盖面是全部配置域加 retired 域。
|
|
4
6
|
* 目录被当作**一个 scope 的全集**(`coverage: "scope"`):目录里没有的域文件就是从未设过。
|
|
5
7
|
*
|
|
6
|
-
* 本模块不碰 `node:fs`:读写文件由入口 `bin/settings-schema.mjs` 注入(
|
|
8
|
+
* 本模块不碰 `node:fs`:读写文件由入口 `bin/settings-schema.mjs` 注入(真绑定是 `./config-dir-io` 的 `nodeConfigDirectoryIo`,写盘用的是 `./atomic-write` 那一只原子写器 —— 写法与文件模式在那里一处定),所以测试可以直接驱动它。
|
|
7
9
|
* 不进主桶 —— 它是 bin 的实现,不是契约面。
|
|
8
10
|
*
|
|
9
|
-
* 读哪些文件:目录里 `<名>.json`,名是配置域或retired 域(`runtime`)—— 便携与否不论(离线的 `workers.json` 一样迁);别的文件
|
|
11
|
+
* 读哪些文件:目录里 `<名>.json`,名是配置域或 retired 域(`runtime`)—— 便携与否不论(离线的 `workers.json` 一样迁);别的文件
|
|
10
12
|
* (`remote-exec.json`、隐藏文件、不认识的名)不读不动。
|
|
11
13
|
*
|
|
12
14
|
* 零域文档分两形:**有条目却没有域文档**(任何非 `.` 开头的条目 —— 子目录、说明文件、不认识的名)⇒ 退出 2;**目录真空**(零条目,或只有以 `.` 开头的条目)⇒ 照常走判定,
|
|
13
15
|
* 零收据 = noop 退出 0 —— 新部署的空 `config.d` 是合法态,「没有东西可迁」不是「指错目录」(搬空之后再跑同一目录也因此幂等)。
|
|
14
16
|
*
|
|
15
|
-
* 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见
|
|
17
|
+
* 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 `migrateConfigDirectory`),**一字不写**;
|
|
16
18
|
* 2 = 用法 / 读目录 / 读文件错,或某个域文件是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
|
|
17
19
|
* (多半是填错了目录:要的是 `config.d` 目录本身;`<目录>/config.d` 在场时句子直接点它)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
|
|
18
20
|
* 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
|
|
@@ -21,17 +23,16 @@
|
|
|
21
23
|
* 退出码 0 / 1 / 3 时 stdout 恰两行:第 1 行 = **唯一结论行** `outcome=<refused|applied|noop|pending> written=<true|false>`;第 2 行 = JSON 报告
|
|
22
24
|
* (`event: "stored_documents_migrated"`,逐条收据只点名坐标与处置、不带值),同带 `outcome` / `written`。`written` 由 `outcome` 派生
|
|
23
25
|
* (只有 `applied` 写盘)。**键名带时态**:`written=true` 才有 `applied`;没写(refused / noop / pending)时是 `wouldApply`。
|
|
24
|
-
* 结论行在**写盘成功之后**才出(写盘抛错 ⇒ 没有 `outcome=applied`)。写盘次序:先写改过 / 新建的文件,最后删被移除的retired 域文件 ——
|
|
26
|
+
* 结论行在**写盘成功之后**才出(写盘抛错 ⇒ 没有 `outcome=applied`)。写盘次序:先写改过 / 新建的文件,最后删被移除的 retired 域文件 ——
|
|
25
27
|
* 中途断掉再跑一次,迁移照样恒等收尾(已搬过去的值在新位上,旧文件里同名键按「新位赢」丢弃)。
|
|
26
28
|
*/
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
import { isDomainName, isRetiredDomainName } from "./types.js";
|
|
29
|
+
import { reportReceipt, joinDirPath, migrateConfigDirectory } from "./config-dir-migration.js";
|
|
30
|
+
import { MIGRATE_COMMAND_TEMPLATE, migrateCommandFor } from "./retired-form.js";
|
|
31
|
+
import { DOMAINS, RETIRED_DOMAINS } from "./types.js";
|
|
32
|
+
/** 命令行的覆盖面:全部配置域加 retired 域(便携与否不论 —— 离线的 `workers.json` 一样迁)。 */
|
|
33
|
+
const COVERED_DOCUMENTS = [...DOMAINS, ...Object.keys(RETIRED_DOMAINS)];
|
|
33
34
|
const USAGE = [
|
|
34
|
-
`usage: ${
|
|
35
|
+
`usage: ${MIGRATE_COMMAND_TEMPLATE} [--check]`,
|
|
35
36
|
" Runs the one-time migrations (@sema-agent/settings-schema/one-time-migrations) over every <domain>.json in the directory, read as one",
|
|
36
37
|
" scope (a missing domain file = never set): writes the rewritten files in place and removes retired-domain files (runtime.json) only when",
|
|
37
38
|
" nothing is refused and every document it would write reads as a whole domain; --check runs the same verdict and only reports (exit 3",
|
|
@@ -55,61 +56,6 @@ function writtenOf(outcome) {
|
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
|
-
/** 报告里的一张收据(坐标出门形;不带值)。 */
|
|
59
|
-
function reportReceipt(r) {
|
|
60
|
-
const base = { migration: r.migration, document: r.document, path: formatConfigPath(r.path), appliedAs: r.appliedAs };
|
|
61
|
-
switch (r.appliedAs) {
|
|
62
|
-
case "moved":
|
|
63
|
-
return { ...base, to: `${r.to.document}:${formatConfigPath(r.to.path)}` };
|
|
64
|
-
case "refused":
|
|
65
|
-
return { ...base, reason: r.reason };
|
|
66
|
-
case "rewritten":
|
|
67
|
-
case "dropped-shadowed":
|
|
68
|
-
case "dropped-inert":
|
|
69
|
-
return base;
|
|
70
|
-
default: {
|
|
71
|
-
const unhandled = r;
|
|
72
|
-
throw new Error(`settings-schema: unhandled receipt ${JSON.stringify(unhandled)}`);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
/** 整域终判的一条拒(issue 来自 `parseDomain`,已过本包唯一的脱敏器 —— 坐标段与句子都是出门形)。 */
|
|
77
|
-
function wholeDomainRefusal(document, path, reason) {
|
|
78
|
-
return { migration: null, document, path: formatConfigPath(path), appliedAs: "refused", reason };
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* 迁移结果 → 结论。顺序:迁移步拒 → 无改动 ⇒ noop → **整域终判**(要写回的每个域文档 —— 改过的、新建的 —— 过读侧那一只
|
|
82
|
-
* `parseDomain`:名字 / 路由唯一这类跨行规则只有整域判得出)→ 有改动。整域终判在 `--check` 之前:否则同一个目录 `--check` 报 pending、
|
|
83
|
-
* 真跑写出一份读侧必拒的文档。没改的文件不归这里判(迁移不碰它,它读不读得进是读者的事)。
|
|
84
|
-
*/
|
|
85
|
-
function verdictOf(input, migrated, receipts) {
|
|
86
|
-
const refusedReceipts = receipts.filter((r) => r.appliedAs === "refused");
|
|
87
|
-
if (refusedReceipts.length > 0) {
|
|
88
|
-
return { kind: "refused", refused: refusedReceipts.map(reportReceipt), why: `${refusedReceipts.length} coordinate(s) cannot be migrated (see "refused") — fix them by hand and re-run` };
|
|
89
|
-
}
|
|
90
|
-
if (receipts.length === 0)
|
|
91
|
-
return { kind: "noop" };
|
|
92
|
-
const refused = [];
|
|
93
|
-
for (const [name, doc] of Object.entries(migrated)) {
|
|
94
|
-
if (!isDomainName(name) || (Object.prototype.hasOwnProperty.call(input, name) && input[name] === doc))
|
|
95
|
-
continue;
|
|
96
|
-
try {
|
|
97
|
-
parseDomain(name, doc);
|
|
98
|
-
}
|
|
99
|
-
catch (err) {
|
|
100
|
-
if (err instanceof z.ZodError)
|
|
101
|
-
for (const i of err.issues)
|
|
102
|
-
refused.push(wholeDomainRefusal(name, i.path, i.message));
|
|
103
|
-
else
|
|
104
|
-
refused.push(wholeDomainRefusal(name, [], err instanceof Error ? err.message : String(err)));
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
if (refused.length > 0) {
|
|
108
|
-
const documents = [...new Set(refused.map((r) => String(r.document)))];
|
|
109
|
-
return { kind: "refused", refused, why: `the migrated ${documents.join(" / ")} document(s) do not read as a whole domain (see "refused") — fix them by hand and re-run` };
|
|
110
|
-
}
|
|
111
|
-
return { kind: "changes" };
|
|
112
|
-
}
|
|
113
59
|
/** 一次发出报告:结论行 + JSON 行(一次 `stdout` 调用)。 */
|
|
114
60
|
function reportOf(outcome, dir, applied, refused) {
|
|
115
61
|
const written = writtenOf(outcome);
|
|
@@ -118,18 +64,17 @@ function reportOf(outcome, dir, applied, refused) {
|
|
|
118
64
|
outcome,
|
|
119
65
|
written,
|
|
120
66
|
dir,
|
|
121
|
-
[written ? "applied" : "wouldApply"]: applied.map(reportReceipt),
|
|
67
|
+
[written ? "applied" : "wouldApply"]: applied.map((r) => reportReceipt(r)),
|
|
122
68
|
refused,
|
|
123
69
|
});
|
|
124
70
|
return `outcome=${outcome} written=${written}\n${json}\n`;
|
|
125
71
|
}
|
|
126
|
-
|
|
127
|
-
/** 「有条目却没有域文档」的拒句:点名这个目录、说要的是 `config.d` 目录本身;`<dir>/config.d` 在场(探一次)就直接点它 —— 最常见的错是填了它的上一层。 */
|
|
72
|
+
/** 「有条目却没有域文档」的拒句:点名这个目录、说要的是 `config.d` 目录本身;`<dir>/config.d` 在场(探一次)就直接点它 —— 最常见的错是填了它的上一层。指路的命令里路径过 shell 引用,可直接粘贴。 */
|
|
128
73
|
async function noDomainDocumentsSentence(dir, io) {
|
|
129
|
-
const nested =
|
|
74
|
+
const nested = joinDirPath(dir, "config.d");
|
|
130
75
|
const nestedExists = await io.readDir(nested).then(() => true, () => false);
|
|
131
76
|
const base = `settings-schema: no domain documents in ${dir} (a domain document is <domain>.json, e.g. models.json) — the argument is the config.d directory itself`;
|
|
132
|
-
return nestedExists ? `${base}; ${nested} exists — run: ${
|
|
77
|
+
return nestedExists ? `${base}; ${nested} exists — run: ${migrateCommandFor(nested)}` : base;
|
|
133
78
|
}
|
|
134
79
|
export async function runSettingsSchemaCli(argv, io) {
|
|
135
80
|
const [command, ...rest] = argv;
|
|
@@ -140,73 +85,36 @@ export async function runSettingsSchemaCli(argv, io) {
|
|
|
140
85
|
io.stderr(`${USAGE}\n`);
|
|
141
86
|
return 2;
|
|
142
87
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
catch (err) {
|
|
148
|
-
io.stderr(`settings-schema: cannot read directory ${dir}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
149
|
-
return 2;
|
|
150
|
-
}
|
|
151
|
-
const docs = {};
|
|
152
|
-
for (const name of [...names].sort()) {
|
|
153
|
-
if (name.startsWith(".") || !name.endsWith(".json"))
|
|
154
|
-
continue;
|
|
155
|
-
const base = name.slice(0, -".json".length);
|
|
156
|
-
if (!isDomainName(base) && !isRetiredDomainName(base))
|
|
157
|
-
continue;
|
|
158
|
-
let text;
|
|
159
|
-
try {
|
|
160
|
-
text = await io.readFile(join(dir, name));
|
|
161
|
-
}
|
|
162
|
-
catch (err) {
|
|
163
|
-
io.stderr(`settings-schema: cannot read ${name}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
164
|
-
return 2;
|
|
165
|
-
}
|
|
166
|
-
try {
|
|
167
|
-
docs[base] = judgeConfigDText(base, text); // the one config.d file judge: empty / whitespace / bad JSON / not an object ⇒ same sentence as FileConfigStore
|
|
168
|
-
}
|
|
169
|
-
catch (err) {
|
|
170
|
-
io.stderr(`settings-schema: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
88
|
+
const m = await migrateConfigDirectory(dir, { documents: COVERED_DOCUMENTS, io, check });
|
|
89
|
+
switch (m.kind) {
|
|
90
|
+
case "unreadable":
|
|
91
|
+
io.stderr(`settings-schema: ${m.reason}\n`);
|
|
171
92
|
return 2;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const applied = m.receipts.filter((r) => r.appliedAs !== "refused");
|
|
182
|
-
const verdict = verdictOf(docs, m.docs, m.receipts);
|
|
183
|
-
switch (verdict.kind) {
|
|
93
|
+
case "no-documents":
|
|
94
|
+
if (m.entries.some((name) => !name.startsWith("."))) {
|
|
95
|
+
// The directory has entries and none of them is a domain document: a no-op report (rc 0) would read as "this scope is migrated" for a
|
|
96
|
+
// directory the command never looked into. A directory with nothing visible in it (empty, or only dot entries) is a legitimate scope.
|
|
97
|
+
io.stderr(`${await noDomainDocumentsSentence(dir, io)}\n`);
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
io.stdout(reportOf("noop", dir, [], []));
|
|
101
|
+
return 0;
|
|
184
102
|
case "refused":
|
|
185
|
-
io.stdout(reportOf("refused", dir, applied,
|
|
186
|
-
io.stderr(`settings-schema: ${
|
|
103
|
+
io.stdout(reportOf("refused", dir, m.applied, m.refused));
|
|
104
|
+
io.stderr(`settings-schema: ${m.why}; nothing was written\n`);
|
|
187
105
|
return 1;
|
|
188
106
|
case "noop":
|
|
189
107
|
io.stdout(reportOf("noop", dir, [], []));
|
|
190
108
|
return 0;
|
|
191
|
-
case "
|
|
192
|
-
|
|
109
|
+
case "pending":
|
|
110
|
+
io.stdout(reportOf("pending", dir, m.applied, []));
|
|
111
|
+
return 3;
|
|
112
|
+
case "applied":
|
|
113
|
+
io.stdout(reportOf("applied", dir, m.applied, [])); // 写盘成功之后才报 applied
|
|
114
|
+
return 0;
|
|
193
115
|
default: {
|
|
194
|
-
const unhandled =
|
|
195
|
-
throw new Error(`settings-schema: unhandled
|
|
116
|
+
const unhandled = m;
|
|
117
|
+
throw new Error(`settings-schema: unhandled migration result ${JSON.stringify(unhandled)}`);
|
|
196
118
|
}
|
|
197
119
|
}
|
|
198
|
-
if (check) {
|
|
199
|
-
io.stdout(reportOf("pending", dir, applied, []));
|
|
200
|
-
return 3;
|
|
201
|
-
}
|
|
202
|
-
for (const [name, doc] of Object.entries(m.docs)) {
|
|
203
|
-
if (Object.prototype.hasOwnProperty.call(docs, name) && docs[name] === doc)
|
|
204
|
-
continue;
|
|
205
|
-
await io.writeFileAtomic(join(dir, `${name}.json`), `${JSON.stringify(doc, null, 2)}\n`);
|
|
206
|
-
}
|
|
207
|
-
for (const name of Object.keys(docs))
|
|
208
|
-
if (!Object.prototype.hasOwnProperty.call(m.docs, name))
|
|
209
|
-
await io.removeFile(join(dir, `${name}.json`));
|
|
210
|
-
io.stdout(reportOf("applied", dir, applied, [])); // 写盘成功之后才报 applied
|
|
211
|
-
return 0;
|
|
212
120
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `migrateConfigDirectory`(`./config-dir-migration`)的**真 I/O 绑定**(Node 专用):读目录 / 读文件 / 删文件走 `node:fs/promises`,写盘走本包唯一的原子写器
|
|
3
|
+
* `./atomic-write`(同目录临时文件 → rename;`config-document` 类的模式规则:新建 0o644 经 umask、已有的沿用原模式;目标是符号链接则写到它指向的真文件)。
|
|
4
|
+
* 命令行入口与引擎起服的目录迁移注入同一份,写法与文件模式因此只在原子写器那一处定。
|
|
5
|
+
*/
|
|
6
|
+
import { readdir, readFile, unlink } from "node:fs/promises";
|
|
7
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
8
|
+
export const nodeConfigDirectoryIo = {
|
|
9
|
+
readDir: (path) => readdir(path),
|
|
10
|
+
readFile: (path) => readFile(path, "utf8"),
|
|
11
|
+
writeFileAtomic: async (path, text) => writeFileAtomic(path, text, "config-document"), // the files this migration writes are config.d domain documents
|
|
12
|
+
removeFile: (path) => unlink(path),
|
|
13
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type MigrationReceipt } from "./one-time-migrations.js";
|
|
2
|
+
import { type DomainName, type RetiredDomainName } from "./types.js";
|
|
3
|
+
/** 注入的 I/O(本模块不碰 `node:fs`)。`writeFileAtomic` 必须原子(写临时文件再改名):半写的域文档比旧文档更糟。 */
|
|
4
|
+
export interface ConfigDirectoryIo {
|
|
5
|
+
readDir(path: string): Promise<string[]>;
|
|
6
|
+
readFile(path: string): Promise<string>;
|
|
7
|
+
writeFileAtomic(path: string, text: string): Promise<void>;
|
|
8
|
+
removeFile(path: string): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
/** 一条拒绝 —— 迁移步的拒(收据 `refused`)与整域终判的拒**同一套形**:`migration`(整域终判拒为 `null`)/ `document` / `path`(相对该域文档,出门形)/ `appliedAs: "refused"` / `reason`。
|
|
11
|
+
* 句子来自本包唯一的脱敏器,坐标段与句子都是出门形、不带值。 */
|
|
12
|
+
export type ConfigDirectoryRefusal = {
|
|
13
|
+
readonly migration: string | null;
|
|
14
|
+
readonly document: string;
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly appliedAs: "refused";
|
|
17
|
+
readonly reason: string;
|
|
18
|
+
};
|
|
19
|
+
export type ConfigDirectoryMigration = {
|
|
20
|
+
readonly kind: "unreadable";
|
|
21
|
+
readonly reason: string;
|
|
22
|
+
} | {
|
|
23
|
+
readonly kind: "no-documents";
|
|
24
|
+
readonly entries: readonly string[];
|
|
25
|
+
} | {
|
|
26
|
+
readonly kind: "refused";
|
|
27
|
+
readonly read: readonly string[];
|
|
28
|
+
readonly applied: readonly MigrationReceipt[];
|
|
29
|
+
readonly refused: readonly ConfigDirectoryRefusal[];
|
|
30
|
+
readonly why: string;
|
|
31
|
+
} | {
|
|
32
|
+
readonly kind: "noop";
|
|
33
|
+
readonly read: readonly string[];
|
|
34
|
+
} | {
|
|
35
|
+
readonly kind: "pending";
|
|
36
|
+
readonly read: readonly string[];
|
|
37
|
+
readonly applied: readonly MigrationReceipt[];
|
|
38
|
+
} | {
|
|
39
|
+
readonly kind: "applied";
|
|
40
|
+
readonly read: readonly string[];
|
|
41
|
+
readonly applied: readonly MigrationReceipt[];
|
|
42
|
+
readonly written: readonly string[];
|
|
43
|
+
readonly removed: readonly string[];
|
|
44
|
+
};
|
|
45
|
+
export interface MigrateConfigDirectoryOptions {
|
|
46
|
+
/** 覆盖面:调用方读的域名集(配置域或 retired 域)。拼错的名字 ⇒ 抛 `TypeError`,不让它悄悄变成「什么都没覆盖」。 */
|
|
47
|
+
readonly documents: readonly (DomainName | RetiredDomainName)[];
|
|
48
|
+
readonly io: ConfigDirectoryIo;
|
|
49
|
+
/** 只判、不写:有改动且整域判得过 ⇒ `pending`。默认 `false`。 */
|
|
50
|
+
readonly check?: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** 目录加文件名(目录尾部的分隔符折掉)。包内私有(不进 `./node`)。 */
|
|
53
|
+
export declare const joinDirPath: (dir: string, name: string) => string;
|
|
54
|
+
type RefusedReceipt = Extract<MigrationReceipt, {
|
|
55
|
+
readonly appliedAs: "refused";
|
|
56
|
+
}>;
|
|
57
|
+
/** 一张收据的出门形(坐标只点名、不带值):`refused` 带 `reason`(= {@link ConfigDirectoryRefusal} 的形),`moved` 带 `to`。包内私有 —— 命令行报告用它渲染 `applied`。 */
|
|
58
|
+
export declare function reportReceipt(r: RefusedReceipt): ConfigDirectoryRefusal;
|
|
59
|
+
export declare function reportReceipt(r: MigrationReceipt): Record<string, unknown>;
|
|
60
|
+
/**
|
|
61
|
+
* 对 `dir`(一个 `config.d` 目录)的覆盖面内文档跑一次一次性迁移;语义与结果见文件头。`dir` 与目录内文件名的拼接用 `/`。
|
|
62
|
+
*/
|
|
63
|
+
export declare function migrateConfigDirectory(dir: string, opts: MigrateConfigDirectoryOptions): Promise<ConfigDirectoryMigration>;
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **一个 `config.d` 目录的一次性迁移** —— 「读目录 → 迁 → 整域终判 → 原子写 → 删 retired 域文件」这一条语义的唯一实现。命令行(`migrate <config.d 目录> [--check]`,
|
|
3
|
+
* 见 `./cli`)是它的渲染器(退出码 + 报告行);引擎起服时对本地 `config.d` 的迁移调同一只函数,不按同语义自写一份。
|
|
4
|
+
*
|
|
5
|
+
* **覆盖面 = 调用方读的域名集**({@link MigrateConfigDirectoryOptions.documents}):覆盖面内没有的域文件就是从未设过(一次性迁移的 `coverage: "scope"`);
|
|
6
|
+
* 覆盖面外的文件不读、不判、不写、不删 —— 本调用方没有资格改写它从不消费的数据,它们是坏文件也挡不住覆盖面内的迁移。命令行的覆盖面是全部配置域加 retired 域;
|
|
7
|
+
* 引擎的覆盖面是它本地读腿会读的那几份。**迁移不许越过覆盖面去写**:retired 的 `runtime` 域迁出时要写 `governance` 与 `limits`(`RUNTIME_LIFT_TARGETS`),
|
|
8
|
+
* 覆盖面没把这两个域一并包进来时,目录算不上「一个 scope 的全集」—— 迁移按 `coverage: "partial"` 走(`runtime` 在那一支上以收据 `refused` 拒迁、一字不写),
|
|
9
|
+
* 不会把覆盖面外已有的 `governance.json` / `limits.json` 当作「从未设过」覆盖掉;日后若别的迁移步新建覆盖面外的文档,函数响亮抛(守卫),不静默写。
|
|
10
|
+
*
|
|
11
|
+
* 本模块不碰 `node:fs`:读写由注入的 {@link ConfigDirectoryIo} 做(真绑定是 `./config-dir-io` 的 `nodeConfigDirectoryIo`,写盘用 `./atomic-write` 那一只原子写器与它的模式表),
|
|
12
|
+
* 所以测试可以直接喂内存实现。
|
|
13
|
+
*
|
|
14
|
+
* ## 结果是一个闭集({@link ConfigDirectoryMigration}),调用方穷举
|
|
15
|
+
* · `unreadable` —— 读不了目录 / 某份文件,或某份文档是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象,与 `FileConfigStore` 同一只判、同一句)。一字不写。
|
|
16
|
+
* · `no-documents`—— 覆盖面内一份文档都没找到;带回目录清单 `entries`(调用方自己判这是「填错了目录」还是「真空的新目录」)。一字不写。
|
|
17
|
+
* · `refused` —— 迁移步拒(收据 `refused`:要猜才改得动的形)**或**要写回的文档整域读不进(整域终判:名字 / 路由唯一这类跨行规则只有整域判得出)。一字不写。
|
|
18
|
+
* · `noop` —— 读到了文档、没有要改的。
|
|
19
|
+
* · `pending` —— `check: true` 且有改动、整域判得过:一字不写。
|
|
20
|
+
* · `applied` —— 已写回:先写改过 / 新建的文档,最后删被移除的 retired 域文件 —— 中途断掉再跑一次,迁移照样恒等收尾(已搬过去的值在新位上,旧文件里同名键按「新位赢」丢弃)。
|
|
21
|
+
* 写盘阶段的 I/O 错误**原样抛出**(不吞、不改写):已写完的文档留在新位上,再跑一次幂等收尾。
|
|
22
|
+
*/
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
import { formatConfigPath, parseDomain } from "./config-fns.js";
|
|
25
|
+
import { judgeConfigDText } from "./config-d-file.js";
|
|
26
|
+
import { migrateStoredDocuments } from "./one-time-migrations.js";
|
|
27
|
+
import { RUNTIME_LIFT_TARGETS } from "./runtime-lift-targets.js";
|
|
28
|
+
import { isDomainName, isRetiredDomainName } from "./types.js";
|
|
29
|
+
/** 目录加文件名(目录尾部的分隔符折掉)。包内私有(不进 `./node`)。 */
|
|
30
|
+
export const joinDirPath = (dir, name) => `${dir.replace(/[\\/]+$/, "")}/${name}`;
|
|
31
|
+
export function reportReceipt(r) {
|
|
32
|
+
const base = { migration: r.migration, document: r.document, path: formatConfigPath(r.path), appliedAs: r.appliedAs };
|
|
33
|
+
switch (r.appliedAs) {
|
|
34
|
+
case "moved":
|
|
35
|
+
return { ...base, to: `${r.to.document}:${formatConfigPath(r.to.path)}` };
|
|
36
|
+
case "refused":
|
|
37
|
+
return { ...base, reason: r.reason };
|
|
38
|
+
case "rewritten":
|
|
39
|
+
case "dropped-shadowed":
|
|
40
|
+
case "dropped-inert":
|
|
41
|
+
return base;
|
|
42
|
+
default: {
|
|
43
|
+
const unhandled = r;
|
|
44
|
+
throw new Error(`settings-schema: unhandled receipt ${JSON.stringify(unhandled)}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** 整域终判的一条拒(issue 来自 `parseDomain`,已过本包唯一的脱敏器 —— 坐标段与句子都是出门形)。 */
|
|
49
|
+
function wholeDomainRefusal(document, path, reason) {
|
|
50
|
+
return { migration: null, document, path: formatConfigPath(path), appliedAs: "refused", reason };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 迁移结果 → 结论。顺序:迁移步拒 → 无改动 ⇒ noop → **整域终判**(要写回的每个域文档 —— 改过的、新建的 —— 过读侧那一只
|
|
54
|
+
* `parseDomain`:名字 / 路由唯一这类跨行规则只有整域判得出)→ 有改动。整域终判在 `check` 之前:否则同一个目录 `check` 报 pending、
|
|
55
|
+
* 真跑写出一份读侧必拒的文档。没改的文件不归这里判(迁移不碰它,它读不读得进是读者的事)。
|
|
56
|
+
*/
|
|
57
|
+
function verdictOf(input, migrated, receipts) {
|
|
58
|
+
const refusedReceipts = receipts.filter((r) => r.appliedAs === "refused");
|
|
59
|
+
if (refusedReceipts.length > 0) {
|
|
60
|
+
return { kind: "refused", refused: refusedReceipts.map((r) => reportReceipt(r)), why: `${refusedReceipts.length} coordinate(s) cannot be migrated (see "refused") — fix them by hand and re-run` };
|
|
61
|
+
}
|
|
62
|
+
if (receipts.length === 0)
|
|
63
|
+
return { kind: "noop" };
|
|
64
|
+
const refused = [];
|
|
65
|
+
for (const [name, doc] of Object.entries(migrated)) {
|
|
66
|
+
if (!isDomainName(name) || (Object.prototype.hasOwnProperty.call(input, name) && input[name] === doc))
|
|
67
|
+
continue;
|
|
68
|
+
try {
|
|
69
|
+
parseDomain(name, doc);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
if (err instanceof z.ZodError)
|
|
73
|
+
for (const i of err.issues)
|
|
74
|
+
refused.push(wholeDomainRefusal(name, i.path, i.message));
|
|
75
|
+
else
|
|
76
|
+
refused.push(wholeDomainRefusal(name, [], err instanceof Error ? err.message : String(err)));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (refused.length > 0) {
|
|
80
|
+
const documents = [...new Set(refused.map((r) => r.document))];
|
|
81
|
+
return { kind: "refused", refused, why: `the migrated ${documents.join(" / ")} document(s) do not read as a whole domain (see "refused") — fix them by hand and re-run` };
|
|
82
|
+
}
|
|
83
|
+
return { kind: "changes" };
|
|
84
|
+
}
|
|
85
|
+
const messageOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
86
|
+
/**
|
|
87
|
+
* 对 `dir`(一个 `config.d` 目录)的覆盖面内文档跑一次一次性迁移;语义与结果见文件头。`dir` 与目录内文件名的拼接用 `/`。
|
|
88
|
+
*/
|
|
89
|
+
export async function migrateConfigDirectory(dir, opts) {
|
|
90
|
+
const covered = new Set();
|
|
91
|
+
for (const name of opts.documents) {
|
|
92
|
+
if (!isDomainName(name) && !isRetiredDomainName(name))
|
|
93
|
+
throw new TypeError(`migrateConfigDirectory: "${String(name)}" is not a config domain or a retired domain — the coverage names the documents the caller reads`);
|
|
94
|
+
covered.add(name);
|
|
95
|
+
}
|
|
96
|
+
const { io } = opts;
|
|
97
|
+
let names;
|
|
98
|
+
try {
|
|
99
|
+
names = await io.readDir(dir);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
return { kind: "unreadable", reason: `cannot read directory ${dir}: ${messageOf(err)}` };
|
|
103
|
+
}
|
|
104
|
+
const docs = {};
|
|
105
|
+
for (const name of [...names].sort()) {
|
|
106
|
+
if (name.startsWith(".") || !name.endsWith(".json"))
|
|
107
|
+
continue;
|
|
108
|
+
const base = name.slice(0, -".json".length);
|
|
109
|
+
if (!covered.has(base))
|
|
110
|
+
continue;
|
|
111
|
+
let text;
|
|
112
|
+
try {
|
|
113
|
+
text = await io.readFile(joinDirPath(dir, name));
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
return { kind: "unreadable", reason: `cannot read ${name}: ${messageOf(err)}` };
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
docs[base] = judgeConfigDText(base, text); // the one config.d file judge: empty / whitespace / bad JSON / not an object ⇒ same sentence as FileConfigStore
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
return { kind: "unreadable", reason: messageOf(err) };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const read = Object.keys(docs);
|
|
126
|
+
if (read.length === 0)
|
|
127
|
+
return { kind: "no-documents", entries: names };
|
|
128
|
+
// 目录是不是「一个 scope 的全集」只看一件事:SS-M1 要写的目标域是不是都在覆盖面里(见文件头);不是 ⇒ partial,runtime 的迁移被它自己拒掉。
|
|
129
|
+
const coverage = RUNTIME_LIFT_TARGETS.every((target) => covered.has(target)) ? "scope" : "partial";
|
|
130
|
+
const m = migrateStoredDocuments(docs, { coverage });
|
|
131
|
+
const outside = Object.keys(m.docs).filter((name) => !Object.prototype.hasOwnProperty.call(docs, name) && !covered.has(name));
|
|
132
|
+
if (outside.length > 0) {
|
|
133
|
+
// 守卫:迁移新建了覆盖面外的文档。那份文件调用方没读过,「从未设过」只对覆盖面内成立 —— 写下去会覆盖它。今天不会发生(见文件头);发生了就是迁移步的写入域变了,这里先红。
|
|
134
|
+
throw new Error(`migrateConfigDirectory: the migration produced document(s) outside the coverage (${outside.join(", ")}) — refusing to write files the caller did not read`);
|
|
135
|
+
}
|
|
136
|
+
const applied = m.receipts.filter((r) => r.appliedAs !== "refused");
|
|
137
|
+
const verdict = verdictOf(docs, m.docs, m.receipts);
|
|
138
|
+
switch (verdict.kind) {
|
|
139
|
+
case "refused":
|
|
140
|
+
return { kind: "refused", read, applied, refused: verdict.refused, why: verdict.why };
|
|
141
|
+
case "noop":
|
|
142
|
+
return { kind: "noop", read };
|
|
143
|
+
case "changes":
|
|
144
|
+
break;
|
|
145
|
+
default: {
|
|
146
|
+
const unhandled = verdict;
|
|
147
|
+
throw new Error(`settings-schema: unhandled verdict ${JSON.stringify(unhandled)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (opts.check === true)
|
|
151
|
+
return { kind: "pending", read, applied };
|
|
152
|
+
const written = [];
|
|
153
|
+
const removed = [];
|
|
154
|
+
for (const [name, doc] of Object.entries(m.docs)) {
|
|
155
|
+
if (Object.prototype.hasOwnProperty.call(docs, name) && docs[name] === doc)
|
|
156
|
+
continue;
|
|
157
|
+
await io.writeFileAtomic(joinDirPath(dir, `${name}.json`), `${JSON.stringify(doc, null, 2)}\n`);
|
|
158
|
+
written.push(`${name}.json`);
|
|
159
|
+
}
|
|
160
|
+
for (const name of Object.keys(docs)) {
|
|
161
|
+
if (Object.prototype.hasOwnProperty.call(m.docs, name))
|
|
162
|
+
continue;
|
|
163
|
+
await io.removeFile(joinDirPath(dir, `${name}.json`));
|
|
164
|
+
removed.push(`${name}.json`);
|
|
165
|
+
}
|
|
166
|
+
return { kind: "applied", read, applied, written, removed };
|
|
167
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -29,6 +29,7 @@ export * from "./api/pull-token.js"; // scope-bound token FORM + minting rule (o
|
|
|
29
29
|
export * from "./api/feedback.js"; // /bug report wire contract: FeedbackSubmitRequest (POST body) + FeedbackWire / FeedbackListResponse + the two caps, pure/browser-safe
|
|
30
30
|
export * from "./web-admin-env.js"; // WEB_ADMIN_ENV — every env name an engine reads to reach web-admin (one table; a leaf module), pure/browser-safe
|
|
31
31
|
export * from "./web-admin-credential.js"; // S-698 web-admin credential FILE contract: WebAdminCredentialEnvelope (user-session envelope, strict) + parseWebAdminCredentialFile (bare / envelope / unreadable — the one discrimination law) + judgeWebAdminCredentialDeclaration(env, credential) (pick-exactly-one over the credential's WEB_ADMIN_ENV pair; WEB_ADMIN_CREDENTIALS = access | assertion), pure/browser-safe
|
|
32
|
+
export * from "./web-admin-session-url.js"; // webAdminSessionUrl(webBaseUrl, { worker, sessionId }) — THE web-admin session link template (<webBaseUrl>/chat?agent=<worker>&session=<sessionId>) + parseWebAdminSessionUrl(link) its paired reader, pure/browser-safe
|
|
32
33
|
export * from "./retired-envs.js"; // the retired-env TOMBSTONE table (renamed / replaced / removed, closed set) + retiredEnvRefusal(env) (the one refusal sentence) + buildRetiredEnvWire() — engine boot, catalog wire, shell and web-admin read this one table, pure/browser-safe
|
|
33
34
|
// NOTE: the Node-fs-backed helpers (FileConfigStore + the file editor) are NOT in this barrel — they pull
|
|
34
35
|
// `node:fs`/`node:path` and would break a browser/client bundle that only wants the contract (e.g.
|
package/dist/node.d.ts
CHANGED
|
@@ -10,3 +10,5 @@ export * from "./bundle.js";
|
|
|
10
10
|
export * from "./local-load.js";
|
|
11
11
|
export * from "./scheduler-store-node.js";
|
|
12
12
|
export * from "./prompts-digest.js";
|
|
13
|
+
export { migrateConfigDirectory, type ConfigDirectoryIo, type ConfigDirectoryMigration, type ConfigDirectoryRefusal, type MigrateConfigDirectoryOptions } from "./config-dir-migration.js";
|
|
14
|
+
export { nodeConfigDirectoryIo } from "./config-dir-io.js";
|
package/dist/node.js
CHANGED
|
@@ -10,3 +10,6 @@ export * from "./bundle.js";
|
|
|
10
10
|
export * from "./local-load.js";
|
|
11
11
|
export * from "./scheduler-store-node.js"; // R7 self-wake scheduler store — fs binding of the ./scheduler-store contract
|
|
12
12
|
export * from "./prompts-digest.js"; // prompts 下发包的内容摘要(4.0.0 从 scheduler-store-node 搬来 —— 与调度存储无关;出口路径不变)
|
|
13
|
+
// 目录形的一次性迁移(读目录 → 迁 → 整域终判 → 原子写 → 删 retired 域文件)—— 语义只此一处,命令行是它的渲染器、引擎起服调同一只。显式列名:该模块的包内私有件(出门形/拼路径)不外泄。
|
|
14
|
+
export { migrateConfigDirectory } from "./config-dir-migration.js";
|
|
15
|
+
export { nodeConfigDirectoryIo } from "./config-dir-io.js"; // 上面那只函数的真 I/O 绑定:node:fs + 本包唯一的原子写器(config-document 模式规则)
|
|
@@ -33,6 +33,7 @@ import { outgoingPath, redactSecrets } from "./config-fns.js";
|
|
|
33
33
|
import { LANE_ENV_OWNER, STORE_ENV_KEYS, isLaneEnvKey } from "./execution-lane-index.js";
|
|
34
34
|
import { EXECUTION_LANE_WORDS, MEMORY_ENGINE_BACKENDS, STORE_BACKENDS } from "./execution-lanes.js";
|
|
35
35
|
import { INTEGER } from "./integer.js";
|
|
36
|
+
import { RUNTIME_LIFT_TARGETS } from "./runtime-lift-targets.js";
|
|
36
37
|
import { DOMAIN_SCHEMAS, HOSTS_ARRAY_MAX, HOST_NAME_MAX, NON_SECRET_URL, isDomainName, isRetiredDomainName } from "./types.js";
|
|
37
38
|
import { workerRowVerdict } from "./worker-row-verdict.js";
|
|
38
39
|
import { unwrapSchema } from "./zod-unwrap.js";
|
|
@@ -266,7 +267,7 @@ function stepRuntimeDomain(out, coverage, receipts) {
|
|
|
266
267
|
}
|
|
267
268
|
if (!isPlainObject(raw))
|
|
268
269
|
return refuse("the stored runtime document is not a JSON object — the scope does not read today either; fix it by hand");
|
|
269
|
-
if (coverage === "partial" && !(
|
|
270
|
+
if (coverage === "partial" && !RUNTIME_LIFT_TARGETS.every((target) => has(out, target))) {
|
|
270
271
|
return refuse("a runtime document migrates only together with its scope's governance and limits documents (coverage \"scope\") — on its own there is no telling whether governance was ever set");
|
|
271
272
|
}
|
|
272
273
|
const parsed = RUNTIME_10X.safeParse(raw);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 上一代 web-admin 连接名(`SEMA_REGISTRY_*`)—— **包内私有**(不进主桶、不进 exports 表):上一代名字面只在这里写一次,
|
|
3
|
+
* 两个读者各取所需:墓碑表 `RETIRED_ENVS`(旧名 → `WEB_ADMIN_ENV` 同键的现名)与保留 env 名表(渲染器在改名窗里对还读上一代名的引擎
|
|
4
|
+
* 仍写的三名)。键与 {@link WEB_ADMIN_ENV} 同键 —— 现名与上一代名按键配对,去向从 `WEB_ADMIN_ENV` 取、不手抄。
|
|
5
|
+
*
|
|
6
|
+
* 断言令牌(`assertionToken` / `assertionTokenFile`)是上一代之后才有的两键,没有上一代名;映射型 `Exclude` 掉它们,所以 `WEB_ADMIN_ENV`
|
|
7
|
+
* 日后再加键时,这里必须当场表态(有上一代名就写,没有就加进 `Exclude`)—— 否则编译红。
|
|
8
|
+
*/
|
|
9
|
+
import { WEB_ADMIN_ENV } from "./web-admin-env.js";
|
|
10
|
+
/** 有上一代名的键(= `WEB_ADMIN_ENV` 去掉断言令牌两键)。 */
|
|
11
|
+
export type PreviousWebAdminEnvKey = Exclude<keyof typeof WEB_ADMIN_ENV, "assertionToken" | "assertionTokenFile">;
|
|
12
|
+
/** 上一代名,按 `WEB_ADMIN_ENV` 的键配对。 */
|
|
13
|
+
export declare const PREVIOUS_WEB_ADMIN_ENV: {
|
|
14
|
+
readonly [K in PreviousWebAdminEnvKey]: string;
|
|
15
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 上一代 web-admin 连接名(`SEMA_REGISTRY_*`)—— **包内私有**(不进主桶、不进 exports 表):上一代名字面只在这里写一次,
|
|
3
|
+
* 两个读者各取所需:墓碑表 `RETIRED_ENVS`(旧名 → `WEB_ADMIN_ENV` 同键的现名)与保留 env 名表(渲染器在改名窗里对还读上一代名的引擎
|
|
4
|
+
* 仍写的三名)。键与 {@link WEB_ADMIN_ENV} 同键 —— 现名与上一代名按键配对,去向从 `WEB_ADMIN_ENV` 取、不手抄。
|
|
5
|
+
*
|
|
6
|
+
* 断言令牌(`assertionToken` / `assertionTokenFile`)是上一代之后才有的两键,没有上一代名;映射型 `Exclude` 掉它们,所以 `WEB_ADMIN_ENV`
|
|
7
|
+
* 日后再加键时,这里必须当场表态(有上一代名就写,没有就加进 `Exclude`)—— 否则编译红。
|
|
8
|
+
*/
|
|
9
|
+
import { WEB_ADMIN_ENV } from "./web-admin-env.js";
|
|
10
|
+
/** 上一代名,按 `WEB_ADMIN_ENV` 的键配对。 */
|
|
11
|
+
export const PREVIOUS_WEB_ADMIN_ENV = {
|
|
12
|
+
url: "SEMA_REGISTRY_URL",
|
|
13
|
+
token: "SEMA_REGISTRY_TOKEN",
|
|
14
|
+
tokenFile: "SEMA_REGISTRY_TOKEN_FILE",
|
|
15
|
+
worker: "SEMA_REGISTRY_WORKER",
|
|
16
|
+
liveUrl: "SEMA_REGISTRY_LIVE_URL",
|
|
17
|
+
webUrl: "SEMA_REGISTRY_WEB_URL",
|
|
18
|
+
dryRun: "SEMA_REGISTRY_DRY_RUN",
|
|
19
|
+
adminToken: "SEMA_REGISTRY_ADMIN_TOKEN",
|
|
20
|
+
};
|
package/dist/retired-envs.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* 退役 env 名的**墓碑表** —— 一张表,本包是它的唯一属主:引擎(server)起服时按它拒启、目录 wire 的 `retired[]` 由它投影,
|
|
3
3
|
* 壳在起本机引擎之前、web-admin 在给 worker 渲染 env 之前读同一张(谁都不再手抄、不再从别人的构建产物里正则抽)。
|
|
4
4
|
*
|
|
5
|
-
* **进表的判据(一条)
|
|
6
|
-
* 改名的那一版只登记现名,旧名等引擎换读现名之后的那一版再进表。
|
|
5
|
+
* **进表的判据(一条):没有带着本表的引擎还读这个名。** 更老的引擎内带的是更老的契约版本,看不见这张表,它们读不读某个名由它们自己的版本定;
|
|
6
|
+
* 一个名字只要还有带着本表的引擎把它当活旋钮读,进表就等于让照旧配置的部署拒启 —— 改名的那一版只登记现名,旧名等引擎换读现名之后的那一版再进表。
|
|
7
7
|
*
|
|
8
8
|
* 🪦 **墓碑是永久的响亮拒,不设到期**。一行墓碑就是那句拒启句本身,成本一行;删掉它 = 旧名重新被悄悄忽略。
|
|
9
9
|
* ⇒ 本表只增不减,**唯一的删除条件是这个名字要被赋予新含义**:那一次提交同时删掉这一行,并在发布说明里写明旧名改作何用。
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*
|
|
26
26
|
* 行形 = 字面元组(第三元是族词),一行一个旧名。纯数据 + 纯函数,零 IO,browser-safe。
|
|
27
27
|
*/
|
|
28
|
+
import { PREVIOUS_WEB_ADMIN_ENV } from "./previous-web-admin-env.js";
|
|
28
29
|
import { WEB_ADMIN_ENV } from "./web-admin-env.js";
|
|
29
30
|
/** 墓碑拒启码(机读;拒句方括号里)。 */
|
|
30
31
|
export const RETIRED_ENV_REFUSAL_CODE = "config.env_key_retired";
|
|
@@ -38,6 +39,16 @@ export const RETIRED_ENVS = [
|
|
|
38
39
|
["TIDB_DATABASE", "MYSQL_DATABASE", "renamed"],
|
|
39
40
|
["TIDB_POOL_SIZE", "MYSQL_POOL_SIZE", "renamed"],
|
|
40
41
|
["ANTHROPIC_BASEURL", "ANTHROPIC_BASE_URL", "renamed"],
|
|
42
|
+
// web-admin 连接名上一代的 `SEMA_REGISTRY_*` 八名,直指同键的现名(一跳;旧名取上一代名表、去向取 `WEB_ADMIN_ENV`,本行不写任何这类字面)。
|
|
43
|
+
// 引擎换读现名之后才进表:读侧同代要求 —— 引擎至少是换读现名的那一版,更老的引擎仍把它们当活旋钮读。
|
|
44
|
+
[PREVIOUS_WEB_ADMIN_ENV.url, WEB_ADMIN_ENV.url, "renamed"],
|
|
45
|
+
[PREVIOUS_WEB_ADMIN_ENV.token, WEB_ADMIN_ENV.token, "renamed"],
|
|
46
|
+
[PREVIOUS_WEB_ADMIN_ENV.tokenFile, WEB_ADMIN_ENV.tokenFile, "renamed"],
|
|
47
|
+
[PREVIOUS_WEB_ADMIN_ENV.worker, WEB_ADMIN_ENV.worker, "renamed"],
|
|
48
|
+
[PREVIOUS_WEB_ADMIN_ENV.liveUrl, WEB_ADMIN_ENV.liveUrl, "renamed"],
|
|
49
|
+
[PREVIOUS_WEB_ADMIN_ENV.webUrl, WEB_ADMIN_ENV.webUrl, "renamed"],
|
|
50
|
+
[PREVIOUS_WEB_ADMIN_ENV.dryRun, WEB_ADMIN_ENV.dryRun, "renamed"],
|
|
51
|
+
[PREVIOUS_WEB_ADMIN_ENV.adminToken, WEB_ADMIN_ENV.adminToken, "renamed"],
|
|
41
52
|
// web-admin 连接名两代前的 `CONFIG_CENTER_*` / `REGISTRY_ADMIN_TOKEN`,直指现名(一跳)。
|
|
42
53
|
["CONFIG_CENTER_URL", WEB_ADMIN_ENV.url, "renamed"],
|
|
43
54
|
["CONFIG_CENTER_TOKEN", WEB_ADMIN_ENV.token, "renamed"],
|
package/dist/retired-form.d.ts
CHANGED
|
@@ -9,8 +9,13 @@
|
|
|
9
9
|
* 墓碑留下 —— 到那时拒句里的迁移 id 由删码那一车改写(登记表那一行的 `removal` 点名了这件事)。
|
|
10
10
|
*/
|
|
11
11
|
import { z } from "zod";
|
|
12
|
-
/** 迁移命令行的唯一拼写:`npx` 全名形 —— 没装本包的运维粘贴即可跑(裸 bin 名在没装本包的机器上是 `command not found`)。参数是一个 `config.d` 目录。
|
|
12
|
+
/** 迁移命令行的唯一拼写:`npx` 全名形 —— 没装本包的运维粘贴即可跑(裸 bin 名在没装本包的机器上是 `command not found`)。参数是一个 `config.d` 目录。
|
|
13
|
+
* 印出命令的地方只有下面两只:带目录占位的模板 {@link MIGRATE_COMMAND_TEMPLATE}(USAGE、迁移指路句)与带真路径的 {@link migrateCommandFor}(指路到某个具体目录)。 */
|
|
13
14
|
export declare const MIGRATE_COMMAND: "npx @sema-agent/settings-schema migrate";
|
|
15
|
+
/** 命令加目录占位 —— 用法行与迁移指路句里印的形。`<config.d directory>` 是元变量(读者替换成自己的目录),不是参数,所以不引用。 */
|
|
16
|
+
export declare const MIGRATE_COMMAND_TEMPLATE: "npx @sema-agent/settings-schema migrate <config.d directory>";
|
|
17
|
+
/** 指到某个具体目录的命令:路径过 `shellQuote`(含空格 / 特殊字符才加引号)—— 印出来的句子可以直接粘贴去跑。 */
|
|
18
|
+
export declare function migrateCommandFor(configDir: string): string;
|
|
14
19
|
/** 拒句尾的迁移指路:存量由哪一条一次性迁移改写、两种跑法(子路径的函数 / 目录命令;目录是 `config.d` 目录本身,不是它的上一层)。`before` = 旧形不再合法的那一版。
|
|
15
20
|
* 刻意短:它挂在 zod issue 句尾,读侧逐条容错时每一条被丢的条目都要过一遍出门脱敏。 */
|
|
16
21
|
export declare function migrationSignpost(migration: string, before: string): string;
|
package/dist/retired-form.js
CHANGED
|
@@ -9,12 +9,20 @@
|
|
|
9
9
|
* 墓碑留下 —— 到那时拒句里的迁移 id 由删码那一车改写(登记表那一行的 `removal` 点名了这件事)。
|
|
10
10
|
*/
|
|
11
11
|
import { z } from "zod";
|
|
12
|
-
|
|
12
|
+
import { shellQuote } from "./shell-quote.js";
|
|
13
|
+
/** 迁移命令行的唯一拼写:`npx` 全名形 —— 没装本包的运维粘贴即可跑(裸 bin 名在没装本包的机器上是 `command not found`)。参数是一个 `config.d` 目录。
|
|
14
|
+
* 印出命令的地方只有下面两只:带目录占位的模板 {@link MIGRATE_COMMAND_TEMPLATE}(USAGE、迁移指路句)与带真路径的 {@link migrateCommandFor}(指路到某个具体目录)。 */
|
|
13
15
|
export const MIGRATE_COMMAND = "npx @sema-agent/settings-schema migrate";
|
|
16
|
+
/** 命令加目录占位 —— 用法行与迁移指路句里印的形。`<config.d directory>` 是元变量(读者替换成自己的目录),不是参数,所以不引用。 */
|
|
17
|
+
export const MIGRATE_COMMAND_TEMPLATE = `${MIGRATE_COMMAND} <config.d directory>`;
|
|
18
|
+
/** 指到某个具体目录的命令:路径过 `shellQuote`(含空格 / 特殊字符才加引号)—— 印出来的句子可以直接粘贴去跑。 */
|
|
19
|
+
export function migrateCommandFor(configDir) {
|
|
20
|
+
return `${MIGRATE_COMMAND} ${shellQuote(configDir)}`;
|
|
21
|
+
}
|
|
14
22
|
/** 拒句尾的迁移指路:存量由哪一条一次性迁移改写、两种跑法(子路径的函数 / 目录命令;目录是 `config.d` 目录本身,不是它的上一层)。`before` = 旧形不再合法的那一版。
|
|
15
23
|
* 刻意短:它挂在 zod issue 句尾,读侧逐条容错时每一条被丢的条目都要过一遍出门脱敏。 */
|
|
16
24
|
export function migrationSignpost(migration, before) {
|
|
17
|
-
return `data stored before ${before}: run the one-time migration ${migration} (@sema-agent/settings-schema/one-time-migrations, or ${
|
|
25
|
+
return `data stored before ${before}: run the one-time migration ${migration} (@sema-agent/settings-schema/one-time-migrations, or ${MIGRATE_COMMAND_TEMPLATE})`;
|
|
18
26
|
}
|
|
19
27
|
export function retiredFormSentence(form, t) {
|
|
20
28
|
return `${form} was retired in ${t.retiredIn} — now ${t.now}; ${migrationSignpost(t.migration, t.retiredIn)}`;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 包内私有(不进主桶、不进 exports 表):一次性迁移 SS-M1 把 retired 的 `runtime` 域迁出时**写入的目标域** —— 治理三闸去 `governance`,五个限额键去 `limits`。
|
|
3
|
+
* SS-M1 要判「目标域从未设过」还是「设过、设过的赢」,所以它需要同时看见这两份文档:`coverage: "scope"` 的意思就是「缺席的域从未设过」,
|
|
4
|
+
* 而 `"partial"` 只在两份都给了的时候才迁(否则拒)。谁要把一组文档按「覆盖面」交给 SS-M1(目录迁移),覆盖面含这两个域才算一个 scope;一个域也不能少 ——
|
|
5
|
+
* 少了,迁出的旧值会覆盖覆盖面外已有的那份。一处写名字,SS-M1 的偏集检查与目录迁移的覆盖面判定都读它;一道钉拿真跑 SS-M1 的读数核这个常量。
|
|
6
|
+
*/
|
|
7
|
+
export declare const RUNTIME_LIFT_TARGETS: readonly ["governance", "limits"];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 包内私有(不进主桶、不进 exports 表):一次性迁移 SS-M1 把 retired 的 `runtime` 域迁出时**写入的目标域** —— 治理三闸去 `governance`,五个限额键去 `limits`。
|
|
3
|
+
* SS-M1 要判「目标域从未设过」还是「设过、设过的赢」,所以它需要同时看见这两份文档:`coverage: "scope"` 的意思就是「缺席的域从未设过」,
|
|
4
|
+
* 而 `"partial"` 只在两份都给了的时候才迁(否则拒)。谁要把一组文档按「覆盖面」交给 SS-M1(目录迁移),覆盖面含这两个域才算一个 scope;一个域也不能少 ——
|
|
5
|
+
* 少了,迁出的旧值会覆盖覆盖面外已有的那份。一处写名字,SS-M1 的偏集检查与目录迁移的覆盖面判定都读它;一道钉拿真跑 SS-M1 的读数核这个常量。
|
|
6
|
+
*/
|
|
7
|
+
export const RUNTIME_LIFT_TARGETS = ["governance", "limits"];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function shellQuote(arg: string): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 包内私有(不进主桶、不进 exports 表):**一只** shell 引用 —— 命令行印出来、要让人粘贴去跑的命令里,路径一律过它。
|
|
3
|
+
*
|
|
4
|
+
* POSIX 单引号规则:整串包进 `'…'`,串里的 `'` 写成 `'\''`(结束引号 + 转义的引号 + 重新开引号);单引号里除 `'` 外一切字面,所以 `$` / 反引号 / 反斜杠 / 空格 /
|
|
5
|
+
* 换行都不再被 shell 解释。**只有安全字符集里的串原样返回、不加引号**:ASCII 字母数字与 `_ @ + : , . / -`。其余一律引用 —— 包括行首的 `~`(否则被展开成家目录)、
|
|
6
|
+
* `=`(zsh 里行首 `=cmd` 展开成命令路径)、`%`、通配符与非 ASCII(保守:引用永远安全,多包一层引号不会让命令失效)。空串写成 `''`(否则命令少一个参数)。
|
|
7
|
+
* 目标是 POSIX shell(sh / bash / zsh);Windows cmd.exe 不认单引号,不在范围内。
|
|
8
|
+
*/
|
|
9
|
+
const SHELL_SAFE = /^[A-Za-z0-9_@+:,./-]+$/;
|
|
10
|
+
export function shellQuote(arg) {
|
|
11
|
+
if (SHELL_SAFE.test(arg))
|
|
12
|
+
return arg;
|
|
13
|
+
return `'${arg.replaceAll("'", "'\\''")}'`;
|
|
14
|
+
}
|
package/dist/types.js
CHANGED
|
@@ -24,6 +24,7 @@ import { isLoopbackAuthHost } from "./loopback.js";
|
|
|
24
24
|
import { httpUrlFlaws, httpUrlRule } from "./url-shape.js"; // 11.0.0: THE URL shape judge // 11.0.0: the one narrow「本机」predicate (auth relaxation)
|
|
25
25
|
import { secretScanForms } from "./secret-scan.js"; // package-private normalization — the one set of forms both scanners read (not in the barrel)
|
|
26
26
|
import { WEB_ADMIN_ENV } from "./web-admin-env.js"; // the three connection keys the config renderer writes are reserved worker env
|
|
27
|
+
import { PREVIOUS_WEB_ADMIN_ENV } from "./previous-web-admin-env.js"; // the previous generation's names of those three (the renderer still writes them for engines that read them)
|
|
27
28
|
import { retiredEnvRefusal } from "./retired-envs.js"; // the worker env write gate asks the retired-env tombstone table too (one table, one refusal sentence)
|
|
28
29
|
import { Sha256Digest } from "./sha256.js"; // the one `sha256:<hex>` digest form
|
|
29
30
|
import { EXECUTION_LANES, EXECUTION_LANE_WORDS, MEMORY_ENGINE_BACKENDS, SESSION_BACKENDS, STORE_BACKENDS, retiredLaneWordRefusal } from "./execution-lanes.js"; // 7.0.0 M-R2: the lane / store vocabulary (one exported table)
|
|
@@ -1645,13 +1646,22 @@ const WorkerSecretRefs = z.object({
|
|
|
1645
1646
|
* · **删** `MEMORY_BACKEND`(引擎从不读的死名,渲染器不再产出)与 `TIDB_*` 族(引擎的墓碑名,见之拒启;结构字段不再产出它们)。
|
|
1646
1647
|
* MYSQL_* / PG_* 两族归 `runtime.store.db` + `secretRefs.dbPasswordEnv`。
|
|
1647
1648
|
*
|
|
1648
|
-
* `env` / `secretEnv`
|
|
1649
|
-
*
|
|
1649
|
+
* `env` / `secretEnv` 的写闸问**两张表**,各答一个问题,互相独立:
|
|
1650
|
+
* · 本表答「规格作者可不可以自己设这个名」—— 不可以:它归某个结构字段,或归渲染器 / 编排器写,或与渲染器 / 编排器写的名互斥(拒句指路到那个写位);
|
|
1651
|
+
* · 墓碑表 `RETIRED_ENVS`(`./retired-envs`)答「带着墓碑表的引擎还读不读这个名」—— 不读、见之拒启(拒句就是墓碑句 `retiredEnvRefusal` 本身,引擎起服、壳、web-admin 读同一张表同一句)。
|
|
1652
|
+
* 两个问题的读者不同,答案可以同时为「是」。换名窗里就是这样:更老的引擎内带更老的契约、看不见墓碑表,仍读上一代名,渲染器因此对它们仍写上一代的 URL / WORKER / TOKEN
|
|
1653
|
+
* (TOKEN_FILE 与 TOKEN 互斥,同组同入)—— 这四名对第一个问题答「不可以自设」(本表),对第二个问题答「不读」(墓碑表),写闸交两句:墓碑句在前、保留句在后,各说各的事。
|
|
1654
|
+
* 这是**窗期**形。窗关 —— 渲染器不再对任何目标引擎写上一代名、此后没有引擎要读它们 —— 这四名离开本表,两表回到零交集,那才是终态;窗期交集是个写死的闭集,
|
|
1655
|
+
* 有钉盯着,窗关时那一钉翻回零交集。
|
|
1650
1656
|
*
|
|
1651
|
-
* 11.0.0
|
|
1652
|
-
*
|
|
1653
|
-
*
|
|
1654
|
-
*
|
|
1657
|
+
* 11.0.0:`ANTHROPIC_BASEURL`(墓碑名)换成现名 `ANTHROPIC_BASE_URL`;渲染器写的 web-admin 连接三键入表,同 fleet 成员身份两键的「渲染器写、不可配」。
|
|
1658
|
+
* **规则一条:渲染器写的名字是保留名**(运维不可在 `env` / `secretEnv` 里自写)。改名窗里渲染器按目标引擎版本写名,对还读上一代名的引擎写的是
|
|
1659
|
+
* 上一代的 URL / WORKER / TOKEN 三名({@link PREVIOUS_WEB_ADMIN_ENV}),所以这三名与现名三键同一组、保留句同一句(指路同一处)。
|
|
1660
|
+
* 上一代八名都是墓碑(引擎换读现名之后,新引擎见之拒启);其中上面几名因窗期而同时留在本表,另外几名(LIVE_URL / WEB_URL / DRY_RUN / ADMIN_TOKEN)没有谁写、也不与谁互斥,只是墓碑。
|
|
1661
|
+
*
|
|
1662
|
+
* 两把凭证的文件键与断言令牌值键也是保留名(同一条规则):断言令牌(`wat_`)的值键归编排器注入;两个文件键(主凭证 `tokenFile`、断言令牌 `assertionTokenFile`)
|
|
1663
|
+
* 只给不经编排器的手接引擎、由运维直设,不经 worker 规格。文件键与同一份凭证的值键互斥(`judgeWebAdminCredentialDeclaration`:一对里两个都设 ⇒ 拒启),
|
|
1664
|
+
* 规格里写了任一个就与注入撞,拒句点名各自的出路({@link WEB_ADMIN_ASSERTION_SEAT}、{@link WEB_ADMIN_OPERATOR_FILE_SEAT});上一代的 `tokenFile` 与老三名同组同入。
|
|
1655
1665
|
*/
|
|
1656
1666
|
/** web-admin 连接三键的唯一写位(渲染器;没有 WorkerSpec 字段)—— 现名与上一代名指路同一句。 */
|
|
1657
1667
|
const WEB_ADMIN_CONNECTION_SEAT = {
|
|
@@ -1659,6 +1669,12 @@ const WEB_ADMIN_CONNECTION_SEAT = {
|
|
|
1659
1669
|
worker: "(set by the config renderer from the worker's name — not configurable)",
|
|
1660
1670
|
token: "(set by the config renderer from the worker's pull token — not configurable)",
|
|
1661
1671
|
};
|
|
1672
|
+
/** web-admin 凭证的出路:值键由编排器注入;文件键由运维直设(只给不经编排器的手接引擎),不进 worker 规格。没有 WorkerSpec 字段能写它们。
|
|
1673
|
+
* 两把凭证(主凭证 `token` / `tokenFile`、断言令牌 `assertionToken` / `assertionTokenFile`)同一条规则 —— 文件键与同一份凭证的值键互斥,所以文件键同一句出路。 */
|
|
1674
|
+
const WEB_ADMIN_ASSERTION_SEAT = {
|
|
1675
|
+
token: "(injected by the orchestrator from the worker's assertion token — not configurable)",
|
|
1676
|
+
};
|
|
1677
|
+
const WEB_ADMIN_OPERATOR_FILE_SEAT = "(set directly by the operator on a hand-wired engine that is not managed by the orchestrator — never in a worker spec — not configurable)";
|
|
1662
1678
|
const RESERVED_ENV_FIELD = Object.freeze({
|
|
1663
1679
|
PORT: "(fixed at 8090 — not configurable)", MODEL_PROVIDER: "model.provider", MODEL_ID: "model.modelId",
|
|
1664
1680
|
MODEL_REASONING: "model.reasoning", MODEL_CHEAP_ID: "model.cheapId", MODEL_MAX_TOKENS: "model.maxTokens",
|
|
@@ -1682,9 +1698,14 @@ const RESERVED_ENV_FIELD = Object.freeze({
|
|
|
1682
1698
|
[FLEET_SCOPE_ENV]: "(set by the config renderer from the worker's scope — not configurable)",
|
|
1683
1699
|
// ── web-admin 连接三键:渲染器给每台 worker 写 web-admin 根 URL、worker 名与该 worker 的拉取令牌 —— 没有 WorkerSpec 字段能写它们;
|
|
1684
1700
|
// 现名与渲染器对老引擎写的上一代名同一组(见表头注)
|
|
1685
|
-
[WEB_ADMIN_ENV.url]: WEB_ADMIN_CONNECTION_SEAT.url,
|
|
1686
|
-
[WEB_ADMIN_ENV.worker]: WEB_ADMIN_CONNECTION_SEAT.worker,
|
|
1687
|
-
[WEB_ADMIN_ENV.token]: WEB_ADMIN_CONNECTION_SEAT.token,
|
|
1701
|
+
[WEB_ADMIN_ENV.url]: WEB_ADMIN_CONNECTION_SEAT.url, [PREVIOUS_WEB_ADMIN_ENV.url]: WEB_ADMIN_CONNECTION_SEAT.url,
|
|
1702
|
+
[WEB_ADMIN_ENV.worker]: WEB_ADMIN_CONNECTION_SEAT.worker, [PREVIOUS_WEB_ADMIN_ENV.worker]: WEB_ADMIN_CONNECTION_SEAT.worker,
|
|
1703
|
+
[WEB_ADMIN_ENV.token]: WEB_ADMIN_CONNECTION_SEAT.token, [PREVIOUS_WEB_ADMIN_ENV.token]: WEB_ADMIN_CONNECTION_SEAT.token,
|
|
1704
|
+
// ── web-admin 凭证的文件键与断言令牌值键:文件键由运维直设、与同一份凭证的值键互斥 —— 规格里写任一个都与注入撞(主凭证与断言令牌同一规则)。
|
|
1705
|
+
// 上一代的 TOKEN_FILE 与老三名同组同入(换名窗里还读上一代名的引擎读它)。
|
|
1706
|
+
[WEB_ADMIN_ENV.tokenFile]: WEB_ADMIN_OPERATOR_FILE_SEAT, [PREVIOUS_WEB_ADMIN_ENV.tokenFile]: WEB_ADMIN_OPERATOR_FILE_SEAT,
|
|
1707
|
+
[WEB_ADMIN_ENV.assertionToken]: WEB_ADMIN_ASSERTION_SEAT.token,
|
|
1708
|
+
[WEB_ADMIN_ENV.assertionTokenFile]: WEB_ADMIN_OPERATOR_FILE_SEAT,
|
|
1688
1709
|
REQUIRE_EXECUTION_LANE: "runtime.execution.lane (the renderer emits REQUIRE_EXECUTION_LANE=true for every declared lane)",
|
|
1689
1710
|
...Object.fromEntries([...LANE_ENV_OWNER].map(([name, owner]) => [name, laneEnvSeat(name, owner)])),
|
|
1690
1711
|
});
|
package/dist/web-admin-env.d.ts
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* · `adminToken` —— 引擎以管理身份写 web-admin 时用的令牌(起服上报执行公钥)。
|
|
18
18
|
*
|
|
19
19
|
* 渲染器给每台 worker 写的三键(`url` / `worker` / `token`)同时是 worker env 的**保留名**(`RESERVED_WORKER_ENV`):运维在 worker 规格的
|
|
20
|
-
* `env`
|
|
20
|
+
* `env` 里写它们,会与渲染器写的值打架。断言令牌的值键 `assertionToken` 也是保留名(由编排器注入);两把凭证的文件键(`tokenFile` / `assertionTokenFile`)同样是保留名:
|
|
21
|
+
* 只给不经编排器的手接引擎、由运维直设,与同一份凭证的值键互斥,规格里写任一个都与注入撞。
|
|
21
22
|
*/
|
|
22
23
|
export declare const WEB_ADMIN_ENV: {
|
|
23
24
|
readonly url: "SEMA_WEB_ADMIN_URL";
|
package/dist/web-admin-env.js
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* · `adminToken` —— 引擎以管理身份写 web-admin 时用的令牌(起服上报执行公钥)。
|
|
18
18
|
*
|
|
19
19
|
* 渲染器给每台 worker 写的三键(`url` / `worker` / `token`)同时是 worker env 的**保留名**(`RESERVED_WORKER_ENV`):运维在 worker 规格的
|
|
20
|
-
* `env`
|
|
20
|
+
* `env` 里写它们,会与渲染器写的值打架。断言令牌的值键 `assertionToken` 也是保留名(由编排器注入);两把凭证的文件键(`tokenFile` / `assertionTokenFile`)同样是保留名:
|
|
21
|
+
* 只给不经编排器的手接引擎、由运维直设,与同一份凭证的值键互斥,规格里写任一个都与注入撞。
|
|
21
22
|
*/
|
|
22
23
|
export const WEB_ADMIN_ENV = {
|
|
23
24
|
url: "SEMA_WEB_ADMIN_URL",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** 一个会话在 web-admin 里的坐标:它在哪台 worker 上、会话 id 是什么。 */
|
|
2
|
+
export interface WebAdminSessionTarget {
|
|
3
|
+
readonly worker: string;
|
|
4
|
+
readonly sessionId: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* 铸一个会话的绝对网页链接。`webBaseUrl` 必须是干净的绝对 http(s) 基址(无 userinfo / query / fragment;可带路径前缀;尾斜杠折掉);
|
|
8
|
+
* `worker` 与 `sessionId` 必填,逐位 `encodeURIComponent`(含 `&` `#` `=` `?` 的 id 不会串位)。坏输入一律抛,不铸半截链接;
|
|
9
|
+
* 含孤立代理项的串(坏 UTF-16)由 `encodeURIComponent` 抛 `URIError`,不静默替换。
|
|
10
|
+
*/
|
|
11
|
+
export declare function webAdminSessionUrl(webBaseUrl: string, target: WebAdminSessionTarget): string;
|
|
12
|
+
/**
|
|
13
|
+
* {@link webAdminSessionUrl} 的配对解析:从一个链接读回 `worker` / `sessionId`。输入可以是绝对 URL 串、相对串(`/chat?…`)、`URL` 或
|
|
14
|
+
* `URLSearchParams`(聊天页读当前地址栏的形)。**宽读**:缺的那一位不出现在结果里(空值等于缺席);重复参数取第一个(同 `searchParams.get`);
|
|
15
|
+
* 路径与别的查询参数不看。串解析不出来(不是 URL)⇒ 抛 `TypeError`,不静默当成「没有参数」。
|
|
16
|
+
*/
|
|
17
|
+
export declare function parseWebAdminSessionUrl(link: string | URL | URLSearchParams): Partial<WebAdminSessionTarget>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web-admin 里**一个会话的网页链接**:模板函数 {@link webAdminSessionUrl} 与它的配对解析 {@link parseWebAdminSessionUrl} —— 一处定义路由与参数名
|
|
3
|
+
* (纯函数、零 I/O,浏览器安全)。链接形:
|
|
4
|
+
*
|
|
5
|
+
* <webBaseUrl>/chat?agent=<worker>&session=<sessionId>
|
|
6
|
+
*
|
|
7
|
+
* 路由属于 web-admin(聊天页只读 `agent` 与 `session` 两个查询参数);谁要给用户一个会话链接 —— 壳、客户端、服务端文档 —— 调模板函数,不按地址形手拼。
|
|
8
|
+
* 聊天页读写地址栏改读配对解析,于是路径和参数名只有这一份。
|
|
9
|
+
*
|
|
10
|
+
* **两位都必填**:只带 `session` 的链接不够 —— 缺 `agent` 时页面按「URL > 本机记住的 > 第一个在线」挑 worker,再去那一台的会话里找这个 id,
|
|
11
|
+
* 会话不在那一台上就落空。所以模板函数拒绝铸只带一位的链接;解析却**宽读**(旧链接、手输的链接可能缺 `agent`),缺的那一位就是缺席。
|
|
12
|
+
* 链接没有 scope 位(跨空间深链不在这一形里);日后加 scope 时作可选第三位、同一函数,不另开函数。
|
|
13
|
+
*/
|
|
14
|
+
import { httpUrlFlaws, httpUrlRule } from "./url-shape.js";
|
|
15
|
+
/** 链接的路径与两个参数名 —— 本文件私有,唯一一处。 */
|
|
16
|
+
const SESSION_LINK_PATH = "/chat";
|
|
17
|
+
const WORKER_PARAM = "agent";
|
|
18
|
+
const SESSION_PARAM = "session";
|
|
19
|
+
function requiredPart(label, value) {
|
|
20
|
+
if (typeof value !== "string" || value.length === 0)
|
|
21
|
+
throw new TypeError(`webAdminSessionUrl: ${label} is required (a non-empty string) — a session link without it cannot be opened`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 铸一个会话的绝对网页链接。`webBaseUrl` 必须是干净的绝对 http(s) 基址(无 userinfo / query / fragment;可带路径前缀;尾斜杠折掉);
|
|
26
|
+
* `worker` 与 `sessionId` 必填,逐位 `encodeURIComponent`(含 `&` `#` `=` `?` 的 id 不会串位)。坏输入一律抛,不铸半截链接;
|
|
27
|
+
* 含孤立代理项的串(坏 UTF-16)由 `encodeURIComponent` 抛 `URIError`,不静默替换。
|
|
28
|
+
*/
|
|
29
|
+
export function webAdminSessionUrl(webBaseUrl, target) {
|
|
30
|
+
if (typeof webBaseUrl !== "string")
|
|
31
|
+
throw new TypeError("webAdminSessionUrl: webBaseUrl must be a string (an absolute http(s) URL)");
|
|
32
|
+
const flaws = httpUrlFlaws(webBaseUrl, ["userinfo", "query", "fragment"]);
|
|
33
|
+
if (flaws.length > 0)
|
|
34
|
+
throw new TypeError(`webAdminSessionUrl: webBaseUrl ${httpUrlRule(flaws[0])}`);
|
|
35
|
+
const worker = requiredPart("worker", target?.worker);
|
|
36
|
+
const sessionId = requiredPart("sessionId", target?.sessionId);
|
|
37
|
+
const base = new URL(webBaseUrl).href.replace(/\/+$/, ""); // 解析后的规范形:没有 query / fragment(上面已判),尾斜杠折掉
|
|
38
|
+
return `${base}${SESSION_LINK_PATH}?${WORKER_PARAM}=${encodeURIComponent(worker)}&${SESSION_PARAM}=${encodeURIComponent(sessionId)}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* {@link webAdminSessionUrl} 的配对解析:从一个链接读回 `worker` / `sessionId`。输入可以是绝对 URL 串、相对串(`/chat?…`)、`URL` 或
|
|
42
|
+
* `URLSearchParams`(聊天页读当前地址栏的形)。**宽读**:缺的那一位不出现在结果里(空值等于缺席);重复参数取第一个(同 `searchParams.get`);
|
|
43
|
+
* 路径与别的查询参数不看。串解析不出来(不是 URL)⇒ 抛 `TypeError`,不静默当成「没有参数」。
|
|
44
|
+
*/
|
|
45
|
+
export function parseWebAdminSessionUrl(link) {
|
|
46
|
+
let params;
|
|
47
|
+
if (link instanceof URLSearchParams)
|
|
48
|
+
params = link;
|
|
49
|
+
else if (link instanceof URL)
|
|
50
|
+
params = link.searchParams;
|
|
51
|
+
else {
|
|
52
|
+
if (typeof link !== "string" || !URL.canParse(link, "http://relative.invalid"))
|
|
53
|
+
throw new TypeError("parseWebAdminSessionUrl: link is not a URL (pass an absolute URL, a relative `/chat?…` string, a URL or URLSearchParams)");
|
|
54
|
+
params = new URL(link, "http://relative.invalid").searchParams;
|
|
55
|
+
}
|
|
56
|
+
const out = {};
|
|
57
|
+
const worker = params.get(WORKER_PARAM);
|
|
58
|
+
const sessionId = params.get(SESSION_PARAM);
|
|
59
|
+
if (worker !== null && worker !== "")
|
|
60
|
+
out.worker = worker;
|
|
61
|
+
if (sessionId !== null && sessionId !== "")
|
|
62
|
+
out.sessionId = sessionId;
|
|
63
|
+
return out;
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/settings-schema",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.2.0",
|
|
4
4
|
"description": "Sema settings schema — pure config contract (zod domains + effective-config + roster resolution + ref-integrity + remoteExec) shared by sema-web-admin, sema-server, and the TOC desktop/CLI. One source of truth. (Renamed at 1.0.0 from @sema-agent/registry-core — name and docs only, no behavior change; note the range moves 0.19.0 -> 1.0.0, so a dependency must become ^1.0.0. The old names @sema-agent/registry-core <=0.19.0 and @sema-ai/registry-core remain published, frozen, for migration.)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BUSL-1.1",
|