@sema-agent/settings-schema 14.7.0 → 15.0.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 +306 -0
- package/README.md +80 -3
- package/conformance/VERSION +1 -1
- package/conformance/cases/c59-s784-g3-k8s-missing-image/input.json +4 -1
- package/conformance/cases/c61-s784-g3-other-lane-key/input.json +1 -0
- package/conformance/cases/c64-s784-multitenant-local-store/input.json +2 -1
- package/dist/atomic-write.d.ts +15 -0
- package/dist/atomic-write.js +109 -5
- package/dist/catalog-manages.d.ts +15 -0
- package/dist/catalog-manages.js +60 -0
- package/dist/center-refs.d.ts +68 -0
- package/dist/center-refs.js +70 -0
- package/dist/config-dir-migration.js +1 -0
- package/dist/config-fns.js +5 -3
- package/dist/config-root-documents.d.ts +106 -0
- package/dist/config-root-documents.js +93 -0
- package/dist/config-root-node.d.ts +117 -0
- package/dist/config-root-node.js +260 -0
- package/dist/conformance/run.d.ts +3 -0
- package/dist/conformance/run.js +10 -6
- package/dist/conformance/temp-roots.d.ts +31 -0
- package/dist/conformance/temp-roots.js +184 -0
- package/dist/default-model.d.ts +1 -1
- package/dist/default-model.js +5 -4
- package/dist/env-model-table.d.ts +163 -0
- package/dist/env-model-table.js +257 -0
- package/dist/execution-lane-index.d.ts +6 -0
- package/dist/execution-lane-index.js +8 -0
- package/dist/execution-lanes.d.ts +2 -1
- package/dist/execution-lanes.js +6 -4
- package/dist/fleet.d.ts +25 -7
- package/dist/fleet.js +24 -6
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/mcp-request-entry.d.ts +58 -6
- package/dist/mcp-request-entry.js +45 -13
- package/dist/mcp-server-name.d.ts +17 -0
- package/dist/mcp-server-name.js +19 -0
- package/dist/migration-receipt.d.ts +10 -6
- package/dist/migration-receipt.js +5 -3
- package/dist/node.d.ts +1 -0
- package/dist/node.js +2 -0
- package/dist/one-time-migrations.d.ts +4 -2
- package/dist/one-time-migrations.js +29 -1
- package/dist/own-key-read.d.ts +1 -1
- package/dist/remote-exec.d.ts +1 -1
- package/dist/remote-exec.js +4 -2
- package/dist/retired-form.d.ts +19 -0
- package/dist/retired-form.js +24 -1
- package/dist/safety-merge-spec.d.ts +6 -6
- package/dist/tier-words.d.ts +33 -0
- package/dist/tier-words.js +43 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.js +29 -5
- package/dist/worker-row-verdict.js +7 -1
- package/docs/API-SURFACE.md +68 -4
- package/docs/CONSUMER-INTEGRATION.md +72 -2
- package/llms-full.txt +8 -3
- package/llms.txt +9 -3
- package/package.json +2 -2
package/dist/atomic-write.d.ts
CHANGED
|
@@ -29,3 +29,18 @@ export declare function writeFileAtomic(target: string, text: string, kind: Atom
|
|
|
29
29
|
* 入参 `dir` 是已解析到真目录的路径;返回目录的权限位是否让本进程能增删其中的目录项。
|
|
30
30
|
*/
|
|
31
31
|
export declare function directoryAdmitsWrite(dir: string): boolean;
|
|
32
|
+
/** 新建目录的模式:仅属主可读写进(引擎的目录信任判官要求路径上的组件不得组 / 他人可写;0o700 在任何 umask 下都过)。 */
|
|
33
|
+
export declare const OWNER_ONLY_DIRECTORY_MODE = 448;
|
|
34
|
+
/**
|
|
35
|
+
* 原子地**不存在才建** `target`(见文件头「不存在才建」):建成 ⇒ `"created"`;目标名已在(只有 `EEXIST`)⇒ `"present"`(一字不动)。
|
|
36
|
+
* 临时文件独占建出、写完、fsync({@link writeTempExclusive})后 `link` 成目标名。模式 = `kind` 在表里的新建模式经进程 umask(目标按定义不在,没有「已有的模式」可沿用)。
|
|
37
|
+
* 临时文件建不出 / 写不完 / fsync 不了 ⇒ 抛 {@link FsFailure}(落点「往目录里写临时文件」,与 {@link writeFileAtomic} 建临时文件失败同落点同句);`link` 的别的失败原样抛
|
|
38
|
+
* (调用方经 `fsFailureMessage` 取句)。父目录须已存在。
|
|
39
|
+
*/
|
|
40
|
+
export declare function createFileExclusive(target: string, text: string, kind: AtomicFileClass): "created" | "present";
|
|
41
|
+
/**
|
|
42
|
+
* 目录不在就建(连同缺的上级):**逐级** mkdir、每建一级立刻 chmod 到 {@link OWNER_ONLY_DIRECTORY_MODE} —— 模式恰是它,不经 umask(umask 0777 下一次性的递归 mkdir 会先建出
|
|
43
|
+
* 一级 0000 的目录、再也进不去建下一级)。本进程建了 `dir` ⇒ `"created"`;`dir` 已在(或恰被别的进程同时建成)⇒ `"present"`(它的模式一位不动)。
|
|
44
|
+
* 路径上有一节不是目录 ⇒ 抛。
|
|
45
|
+
*/
|
|
46
|
+
export declare function ensureOwnerOnlyDirectory(dir: string): "created" | "present";
|
package/dist/atomic-write.js
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* `mutateSchedulerStore` 的提交点),异步调用方(`writeDomainFile` 等)直接调它 —— 一只写器,没有第二份同样的写法。
|
|
8
8
|
*
|
|
9
9
|
* ## 文件模式:一张按文档类的表({@link ATOMIC_FILE_CLASSES}),不在任何调用点另写
|
|
10
|
-
* · `config-document` ——
|
|
11
|
-
*
|
|
12
|
-
* · `scheduler-store` —— 调度店耐久记录(可能带 principal / session 标识):新建 = 0o600,且**每次写都回到 0o600**(
|
|
13
|
-
*
|
|
10
|
+
* · `config-document` —— 契约 JSON:`config.d` 下的域文档与 `remote-exec.json`,以及配置根上的两份根级文档(决定文档 `config.env-models.json` / 运行记录 `config.migration.<id>.json`)——
|
|
11
|
+
* 只带 env-NAME 或几个固定词、不带秘密值(README 契约),新建 = 0o644;**已有的文件沿用它原来的模式**(运维 chmod 过的文件不被一次写回覆盖)。
|
|
12
|
+
* · `scheduler-store` —— 调度店耐久记录(可能带 principal / session 标识):新建 = 0o600,且**每次写都回到 0o600**(同样经进程 umask:已有的更宽的文件被收回,更严的 umask 下更严)。
|
|
13
|
+
* 新建**文件**的模式照常经进程 umask 遮一道(`writeFile` 的 `mode` 语义):比表更严的 umask 只会更严,不会被这里放宽;沿用的模式原样恢复(不再过 umask)。这是**文件**的律;目录是另一条律(见下面「仅属主可进的目录」)。
|
|
14
14
|
*
|
|
15
15
|
* ## 目标是符号链接
|
|
16
16
|
* 域文件可以是符号链接 —— 运维用链接把 `config.d` 指到别处管理的部署,那是有意的布局,写器不替他改布局。规则一条:**先把目标解析到真文件(`realpath`),
|
|
@@ -20,6 +20,16 @@
|
|
|
20
20
|
* 「链接 ⇒ 真文件、悬空 ⇒ 响亮抛」这一只判法是 {@link resolveSymlinkTarget}:目录迁移的读口(`./config-dir-io`)读目录 / 读文件前也问它,
|
|
21
21
|
* 悬空链接在读阶段与写阶段是**同一句**({@link danglingSymlinkSentence})。
|
|
22
22
|
*
|
|
23
|
+
* ## 不存在才建({@link createFileExclusive})
|
|
24
|
+
* 另一种写法只有一处不同:最后一步不是 `rename` 盖过目标,而是把(已 fsync 的)临时文件 `link` 成目标名 —— 目标名已在(任何东西:文件、目录、哪怕是悬空的符号链接)⇒
|
|
25
|
+
* 内核拒(`EEXIST`),答 `present`、一字不动。读到「缺席」与写入之间别人写下的东西因此不会被吞(一次性迁移铸配置根的决定文档时用它)。
|
|
26
|
+
* 临时文件与目标同目录、模式按同一张表(目标不在 ⇒ 表的新建模式,经进程 umask,与 {@link writeFileAtomic} 的新建同一条规则);临时名无论成败都删掉。文件系统不支持硬链接(`EPERM` / `ENOTSUP` …)⇒ 照常抛,调用方按写失败处置。
|
|
27
|
+
*
|
|
28
|
+
* ## 仅属主可进的目录({@link ensureOwnerOnlyDirectory})
|
|
29
|
+
* 配置根缺席时一次性迁移要建它:建出来的每一级都是 {@link OWNER_ONLY_DIRECTORY_MODE} 的**精确值** —— 逐级 `mkdir` 之后立刻 `chmod`,所以目录的模式**不经进程 umask**:
|
|
30
|
+
* **不受 umask 放宽,也不受 umask 收窄**(前者:`mkdir` 给的 mode 本身就是 0o700,umask 只能清掉位、加不了位;后者:例如 umask 0o277 下 `mkdir` 先出 0o500,紧跟的 `chmod` 把它定回 0o700)。
|
|
31
|
+
* 这是目录的另一条律,与上面「新建文件经进程 umask」不是同一条律的两个特例;已在的目录一位不动。目录模式与文件模式同在本文件一处定。
|
|
32
|
+
*
|
|
23
33
|
* ## 写失败(12.0.0;句子归文件系统失败句的唯一铸点)
|
|
24
34
|
* 三步各一句({@link FsFailure},句子在 `./fs-failure`):临时文件建不出 ⇒ 「cannot write into directory <dir> (<errno>): …」(点名目录 —— 目录不可写时
|
|
25
35
|
* 文件本身没错);沿用原模式的文件给临时文件设模式失败 ⇒ 「cannot set the mode of <file> (<errno>): …」(点名设模式这一步 —— 文件系统不让改模式,目录没有问题);`rename` 失败 ⇒ 「cannot replace <file> (<errno>): …」(点名真文件)。
|
|
@@ -28,7 +38,7 @@
|
|
|
28
38
|
* 写器在建临时文件那一步读了经落点递给铸点(`EPERM` ∧ 可写 ⇒ 真因是不可变标志,首选出路是清标志;`EACCES` ∧ 可写 ⇒ 真因是 ACL / 安全模块;不可写 ⇒ 先让目录可写);`chmod` / `rename` 那两步不读 —— 临时文件刚在同目录建成,可写是已证明的事实,铸点自己给。
|
|
29
39
|
*/
|
|
30
40
|
import { randomBytes } from "node:crypto";
|
|
31
|
-
import { chmodSync, lstatSync, readlinkSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
41
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readlinkSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
32
42
|
import path from "node:path";
|
|
33
43
|
import { FsFailure } from "./fs-failure.js";
|
|
34
44
|
/** 按文档类的模式表(闭集)。 */
|
|
@@ -138,3 +148,97 @@ export function directoryAdmitsWrite(dir) {
|
|
|
138
148
|
const shift = st.uid === euid ? 6 : group ? 3 : 0;
|
|
139
149
|
return ((st.mode >> shift) & 0o3) === 0o3;
|
|
140
150
|
}
|
|
151
|
+
/** 新建目录的模式:仅属主可读写进(引擎的目录信任判官要求路径上的组件不得组 / 他人可写;0o700 在任何 umask 下都过)。 */
|
|
152
|
+
export const OWNER_ONLY_DIRECTORY_MODE = 0o700;
|
|
153
|
+
/**
|
|
154
|
+
* 把临时文件**独占地**(`wx`)建出来、写到写完为止(`writeSync` 可以少写,按返回值续写)、fsync 到盘上:模式是 `mode` **经进程 umask**(`open` 的 `mode` 语义,与 {@link writeFileAtomic}
|
|
155
|
+
* 新建临时文件同一条规则),建出来之后不再改它。`wx` 只管这个临时名没被占;「目标名已在」是 {@link createFileExclusive} 里 `link` 的事。
|
|
156
|
+
*/
|
|
157
|
+
function writeTempExclusive(tmp, text, mode) {
|
|
158
|
+
const fd = openSync(tmp, "wx", mode);
|
|
159
|
+
try {
|
|
160
|
+
const bytes = Buffer.from(text, "utf8");
|
|
161
|
+
for (let written = 0; written < bytes.length;)
|
|
162
|
+
written += writeSync(fd, bytes, written, bytes.length - written);
|
|
163
|
+
fsyncSync(fd);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
closeSync(fd);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* 原子地**不存在才建** `target`(见文件头「不存在才建」):建成 ⇒ `"created"`;目标名已在(只有 `EEXIST`)⇒ `"present"`(一字不动)。
|
|
171
|
+
* 临时文件独占建出、写完、fsync({@link writeTempExclusive})后 `link` 成目标名。模式 = `kind` 在表里的新建模式经进程 umask(目标按定义不在,没有「已有的模式」可沿用)。
|
|
172
|
+
* 临时文件建不出 / 写不完 / fsync 不了 ⇒ 抛 {@link FsFailure}(落点「往目录里写临时文件」,与 {@link writeFileAtomic} 建临时文件失败同落点同句);`link` 的别的失败原样抛
|
|
173
|
+
* (调用方经 `fsFailureMessage` 取句)。父目录须已存在。
|
|
174
|
+
*/
|
|
175
|
+
export function createFileExclusive(target, text, kind) {
|
|
176
|
+
const spec = ATOMIC_FILE_CLASSES[kind];
|
|
177
|
+
const dir = path.dirname(target);
|
|
178
|
+
const tmp = path.join(dir, `.${path.basename(target)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
179
|
+
try {
|
|
180
|
+
writeTempExclusive(tmp, text, spec.newMode);
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
try {
|
|
184
|
+
unlinkSync(tmp);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
/* 临时文件没建成:没什么可清 */
|
|
188
|
+
}
|
|
189
|
+
throw new FsFailure({ landing: "dir-create-temp", path: tmp, directoryWritable: directoryAdmitsWrite(dir) }, e);
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
linkSync(tmp, target);
|
|
193
|
+
return "created";
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
if (e.code === "EEXIST")
|
|
197
|
+
return "present";
|
|
198
|
+
throw e;
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
try {
|
|
202
|
+
unlinkSync(tmp);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
/* 已不在 */
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* 目录不在就建(连同缺的上级):**逐级** mkdir、每建一级立刻 chmod 到 {@link OWNER_ONLY_DIRECTORY_MODE} —— 模式恰是它,不经 umask(umask 0777 下一次性的递归 mkdir 会先建出
|
|
211
|
+
* 一级 0000 的目录、再也进不去建下一级)。本进程建了 `dir` ⇒ `"created"`;`dir` 已在(或恰被别的进程同时建成)⇒ `"present"`(它的模式一位不动)。
|
|
212
|
+
* 路径上有一节不是目录 ⇒ 抛。
|
|
213
|
+
*/
|
|
214
|
+
export function ensureOwnerOnlyDirectory(dir) {
|
|
215
|
+
const target = path.resolve(dir);
|
|
216
|
+
const missing = [];
|
|
217
|
+
for (let at = target; !existsSync(at);) {
|
|
218
|
+
missing.unshift(at);
|
|
219
|
+
const up = path.dirname(at);
|
|
220
|
+
if (up === at)
|
|
221
|
+
break;
|
|
222
|
+
at = up;
|
|
223
|
+
}
|
|
224
|
+
if (missing.length === 0) {
|
|
225
|
+
if (!statSync(target).isDirectory())
|
|
226
|
+
throw Object.assign(new Error(`${target} exists and is not a directory`), { code: "ENOTDIR" });
|
|
227
|
+
return "present";
|
|
228
|
+
}
|
|
229
|
+
let created = false;
|
|
230
|
+
for (const d of missing) {
|
|
231
|
+
try {
|
|
232
|
+
mkdirSync(d, { mode: OWNER_ONLY_DIRECTORY_MODE });
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
if (e.code === "EEXIST" && statSync(d).isDirectory())
|
|
236
|
+
continue; // a concurrent creator made it: theirs, left as it is
|
|
237
|
+
throw e;
|
|
238
|
+
}
|
|
239
|
+
chmodSync(d, OWNER_ONLY_DIRECTORY_MODE);
|
|
240
|
+
if (d === target)
|
|
241
|
+
created = true;
|
|
242
|
+
}
|
|
243
|
+
return created ? "created" : "present";
|
|
244
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一个**已核过是纯 JSON 对象**的条目的 `enabled`:自有数据属性且是布尔 ⇒ 它;其余(缺席、只靠继承、访问器、不是布尔)⇒ `undefined`。不调 getter。
|
|
3
|
+
* 反射本身抛(Proxy 的描述符陷阱)⇒ **照抛**,由调用方的失败臂收:被丢条目身份整份按读不出处置(不带半份身份);原文判官读的是捕获出来的快照,不会走到这里。
|
|
4
|
+
* 包内私有(不进包根):这是「`enabled` 怎么读」的**唯一实现**;条目的形由调用方判一次(被丢条目身份在读 `name` / `id` 之前已判过,这里不再问第二次)。
|
|
5
|
+
*/
|
|
6
|
+
export declare function ownEnabledFlag(entry: object): boolean | undefined;
|
|
7
|
+
/** 一条目录条目**原文**的 `enabled`:先判形(不是纯 JSON 对象 ⇒ `undefined`),再读 {@link ownEnabledFlag}。「显式停用」与计划助手的启用过滤都调它。 */
|
|
8
|
+
export declare function entryEnabledFlag(entry: unknown): boolean | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* 文档里**不是显式停用**的条目数(日志计数用;判官 {@link catalogManagesModelPlane} 就是它 > 0)。入参是 models 文档原文或读过的 `ModelsConfig`(两者同形)。
|
|
11
|
+
* 读不出 ⇒ `TypeError`(见文件头)。
|
|
12
|
+
*/
|
|
13
|
+
export declare function enabledEntryCount(doc: unknown): number;
|
|
14
|
+
/** 这份 models 文档管模型面吗(见文件头的律)。读不出 ⇒ `TypeError`。 */
|
|
15
|
+
export declare function catalogManagesModelPlane(doc: unknown): boolean;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「一份 models 文档管不管模型面」—— **原文半场的唯一判官**(纯、零依赖、浏览器安全)。
|
|
3
|
+
*
|
|
4
|
+
* ## 律(一条)
|
|
5
|
+
* **文档的 `models` 里至少一条不是「显式停用」的条目 ⇔ 管。** 「显式停用」= 纯 JSON 对象、自有数据属性 `enabled` 恰为 `false`
|
|
6
|
+
* ({@link ownEnabledFlag} 是这一读法的**唯一**实现:被丢条目身份的 `enabled`(`./config-fns` 的 `readEntryIdentity`)读的是同一只;{@link entryEnabledFlag} 只是它前面判一次形的入口)。
|
|
7
|
+
* 判官 = 计数 > 0:「管不管」与「几条在管」是同一个事实的两种问法,只有一种读法。
|
|
8
|
+
*
|
|
9
|
+
* · 文档不是纯对象、`models` 缺席或不是数组 ⇒ 0 条(不管);数组里的洞不是条目;
|
|
10
|
+
* · 条目本身不是对象(坏条目)⇒ 不是「显式停用」⇒ 计入 —— 引擎读腿那一半(被丢条目载体:形不对 ⇒ 算管)同答;
|
|
11
|
+
* · 读不出的值(撤销的 Proxy、访问器 / 隐藏的自有键、类实例 —— JSON 解析不出这些)⇒ 抛 `TypeError`,带全包那一句:判官的两个调用方失败方向相反
|
|
12
|
+
* (写者的计划助手:「管」= 要问人;一次性迁移:「管」= 替人铸确认),不替它们猜。
|
|
13
|
+
*
|
|
14
|
+
* 读后表示(引擎的读腿载体半场)不在本包:那一半读的是引擎自己的被丢条目载体,由引擎在本判官之上 ∨ 一格。
|
|
15
|
+
*/
|
|
16
|
+
import { captureJson, isAccessorDescriptor } from "./own-key-read.js";
|
|
17
|
+
import { isJsonArray, isPlainJsonObject } from "./plain-json-object.js";
|
|
18
|
+
/**
|
|
19
|
+
* 一个**已核过是纯 JSON 对象**的条目的 `enabled`:自有数据属性且是布尔 ⇒ 它;其余(缺席、只靠继承、访问器、不是布尔)⇒ `undefined`。不调 getter。
|
|
20
|
+
* 反射本身抛(Proxy 的描述符陷阱)⇒ **照抛**,由调用方的失败臂收:被丢条目身份整份按读不出处置(不带半份身份);原文判官读的是捕获出来的快照,不会走到这里。
|
|
21
|
+
* 包内私有(不进包根):这是「`enabled` 怎么读」的**唯一实现**;条目的形由调用方判一次(被丢条目身份在读 `name` / `id` 之前已判过,这里不再问第二次)。
|
|
22
|
+
*/
|
|
23
|
+
export function ownEnabledFlag(entry) {
|
|
24
|
+
const d = Object.getOwnPropertyDescriptor(entry, "enabled");
|
|
25
|
+
const value = d !== undefined && !isAccessorDescriptor(d) ? d.value : undefined; // 访问器不调(描述符的字段只认自有的,见内核)
|
|
26
|
+
return typeof value === "boolean" ? value : undefined;
|
|
27
|
+
}
|
|
28
|
+
/** 一条目录条目**原文**的 `enabled`:先判形(不是纯 JSON 对象 ⇒ `undefined`),再读 {@link ownEnabledFlag}。「显式停用」与计划助手的启用过滤都调它。 */
|
|
29
|
+
export function entryEnabledFlag(entry) {
|
|
30
|
+
return isPlainJsonObject(entry) ? ownEnabledFlag(entry) : undefined;
|
|
31
|
+
}
|
|
32
|
+
/** 「显式停用」:{@link entryEnabledFlag} 恰为 `false`。 */
|
|
33
|
+
const isExplicitlyDisabled = (entry) => entryEnabledFlag(entry) === false;
|
|
34
|
+
/**
|
|
35
|
+
* 文档里**不是显式停用**的条目数(日志计数用;判官 {@link catalogManagesModelPlane} 就是它 > 0)。入参是 models 文档原文或读过的 `ModelsConfig`(两者同形)。
|
|
36
|
+
* 读不出 ⇒ `TypeError`(见文件头)。
|
|
37
|
+
*/
|
|
38
|
+
export function enabledEntryCount(doc) {
|
|
39
|
+
const copy = captureJson(doc); // 一次自有键快照:之后的读都在副本上(不调 getter、不读继承)
|
|
40
|
+
if (!copy.ok)
|
|
41
|
+
throw new TypeError(`enabledEntryCount: the models document could not be read — ${copy.sentence}`);
|
|
42
|
+
const snapshot = copy.value;
|
|
43
|
+
if (!isPlainJsonObject(snapshot))
|
|
44
|
+
return 0;
|
|
45
|
+
const models = snapshot.models; // 副本是隔离原型的袋:没有继承成员可读
|
|
46
|
+
if (!isJsonArray(models))
|
|
47
|
+
return 0;
|
|
48
|
+
let count = 0;
|
|
49
|
+
for (let i = 0; i < models.length; i++) {
|
|
50
|
+
if (!Object.hasOwn(models, i))
|
|
51
|
+
continue; // 洞不是条目:只认自有下标(`forEach` / `for…of` 会读到被污染的 `Array.prototype[i]`)
|
|
52
|
+
if (!isExplicitlyDisabled(models[i]))
|
|
53
|
+
count++;
|
|
54
|
+
}
|
|
55
|
+
return count;
|
|
56
|
+
}
|
|
57
|
+
/** 这份 models 文档管模型面吗(见文件头的律)。读不出 ⇒ `TypeError`。 */
|
|
58
|
+
export function catalogManagesModelPlane(doc) {
|
|
59
|
+
return enabledEntryCount(doc) > 0;
|
|
60
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 中心引用的**解析口** `resolveCenterRefs(refs, lookup)` —— 纯函数、浏览器安全。
|
|
3
|
+
*
|
|
4
|
+
* 中心名册里 MCP server 的传输带两张**引用表**:stdio 的 `envRefs`(目标 env 键 → 变量名)、http 的 `headerRefs`(目标头名 → 变量名)。名册只存**变量名**、从不存值;
|
|
5
|
+
* 值要在消费方自己的宿主环境里读。此前本包只发引用表的形、不发读它的法:变量没设、设了但读到的不是串、读它的时候抛了,留给每个宿主自己写 —— 各按各的口径处置、各留各的句子。
|
|
6
|
+
* 现在一个读法:**每个成员一句判定,整台一个结论;本口只判,不决定挂不挂**(挂不挂是调用方按自己那条腿的规则定的事)。
|
|
7
|
+
*
|
|
8
|
+
* ## 律(一条)
|
|
9
|
+
* 引用表怎么读、每个变量怎么问、读不出怎么说,只有这一处:
|
|
10
|
+
* · **引用表经公开的记录读器读**({@link readPlainStringRecord}:同一个函数、同一张原因词表 {@link PLAIN_STRING_RECORD_FAULTS}),不另写形状 / 描述符判据。读不出(整张不是纯数据、有成员的变量名不是串、
|
|
11
|
+
* 读成员就抛、叫 `__proto__` 的成员)⇒ 整台结论 `refs_unreadable`,带读器的词,**一个变量都不问** —— 引用表本身不可信时,问了的值也没有可以挂的地方。
|
|
12
|
+
* 读器会把叫 `__proto__` 的串值成员**剔出副本**交其余;本口不许这样悄悄丢一条引用再挂一台少了鉴权的 server,所以同样答 `refs_unreadable`、词 `member_name_reserved`。
|
|
13
|
+
* · **查找函数 `lookup(name)` 由调用方给**,本口**不读 `process.env`**(源里没有 `process` / `globalThis`;宿主怎么读环境、读哪一层环境、要不要缓存,都是调用方的事)。没给 / 不是函数 ⇒ `TypeError`
|
|
14
|
+
* (这是调用方的编程错,不是数据错:不折成成员的 `read_threw`,更不回落任何环境)。
|
|
15
|
+
* · 每个成员恰问一次 `lookup`(按引用表副本的自有键序 —— 整数形的键在前、其余按记录列出的次序;实参只有变量名一个),判成**一个词**({@link CenterRefMemberVerdict}):
|
|
16
|
+
* `resolved`(答了串,空串也是串)· `missing_name`(答 `undefined`:变量没设)· `not_a_string`(答了别的种类 —— 例如对普通对象做 `obj[name]` 读到继承来的函数)· `read_threw`(`lookup` 抛了,不传出)。
|
|
17
|
+
* 一个成员坏了**不早停**:每个成员都有自己的判定,调用方可以一次点名所有缺的变量。同一个变量被两个目标键引用就问两次(缓存是查找函数的事)。
|
|
18
|
+
* · 整台结论只有三个词({@link CenterRefsVerdict}):`resolved`(每个成员都是 `resolved`;零成员也是)· `unresolved`(有成员不是)· `refs_unreadable`(引用表读不出)。
|
|
19
|
+
* **值只在 `resolved` 臂的 `values` 里**;`unresolved` 臂的行只有词,没有任何变量的值(失败读数会进日志)。
|
|
20
|
+
*
|
|
21
|
+
* ## 引用表缺席
|
|
22
|
+
* `refs` 是 `undefined`(这台 server 没声明引用)⇒ `resolved`、零成员、一次都不问 —— 与「空表」同一个答案。别的不是纯数据记录的值(`null`、数组、串……)是 `refs_unreadable`,不当缺席。
|
|
23
|
+
*
|
|
24
|
+
* ## 读数的构造
|
|
25
|
+
* 成员表用 `map`,`values` 用 `Object.defineProperty` + 零原型描述符({@link dataDescriptor})逐键写进一个新对象(定义语义,不走 [[Set]]):`Object.prototype` 上挂了数字键 setter 时目标键 `"0"` 的值也不会被吞;
|
|
26
|
+
* `constructor` / `toString` 这类原型链上的名字作目标键照落成自有数据属性。交出的 `values` 原型是 `Object.prototype`,与读器交出的副本同形。
|
|
27
|
+
*/
|
|
28
|
+
import { type PlainStringRecordFault } from "./mcp-request-entry.js";
|
|
29
|
+
/** 一个成员没解到的**原因词**,闭集,三个:`missing_name`(查找函数答 `undefined` —— 变量没设)· `not_a_string`(答了别的种类)· `read_threw`(查找函数抛了)。消费方按词分派,穷尽 `switch`,不读句子(本口没有句子)。 */
|
|
30
|
+
export declare const CENTER_REF_FAULTS: readonly ["missing_name", "not_a_string", "read_threw"];
|
|
31
|
+
export type CenterRefFault = (typeof CENTER_REF_FAULTS)[number];
|
|
32
|
+
/** 一个成员的判定词:解到了(`resolved`)或{@link CenterRefFault}之一。 */
|
|
33
|
+
export type CenterRefMemberVerdict = "resolved" | CenterRefFault;
|
|
34
|
+
/** 整台结论的词,闭集,三个:`resolved` · `unresolved`(有成员没解到)· `refs_unreadable`(引用表读不出)。 */
|
|
35
|
+
export declare const CENTER_REFS_VERDICTS: readonly ["resolved", "unresolved", "refs_unreadable"];
|
|
36
|
+
export type CenterRefsVerdict = (typeof CENTER_REFS_VERDICTS)[number];
|
|
37
|
+
/** 调用方给的查找函数:变量名 → 它的值(没设 ⇒ `undefined`)。可以抛(本口接住,判 `read_threw`)。本口不替它读任何环境。 */
|
|
38
|
+
export type CenterRefLookup = (name: string) => string | undefined;
|
|
39
|
+
/** 一个成员的判定:`key` = 目标键(env 键 / 头名,按引用表所写)、`ref` = 它引用的变量名(按所写)、`verdict` = 判定词。行里没有值。 */
|
|
40
|
+
export interface CenterRefMember {
|
|
41
|
+
readonly key: string;
|
|
42
|
+
readonly ref: string;
|
|
43
|
+
readonly verdict: CenterRefMemberVerdict;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* {@link resolveCenterRefs} 的读数,按 `verdict` 分三臂(穷尽 `switch`):
|
|
47
|
+
* · `resolved`:`members` 每行都是 `resolved`;`values` = 目标键 → 解到的串(新铸的朴素对象);
|
|
48
|
+
* · `unresolved`:`members` 里至少一行不是 `resolved`(行的次序 = 询问的次序,见文件头注);没有 `values`;
|
|
49
|
+
* · `refs_unreadable`:引用表没读成 —— `fault` = 读器的原因词({@link PlainStringRecordFault}),坏在某个成员时 `member` 点名它的回显形;没问过任何变量。
|
|
50
|
+
*/
|
|
51
|
+
export type CenterRefsResolution = {
|
|
52
|
+
readonly verdict: "resolved";
|
|
53
|
+
readonly members: readonly CenterRefMember[];
|
|
54
|
+
readonly values: Readonly<Record<string, string>>;
|
|
55
|
+
} | {
|
|
56
|
+
readonly verdict: "unresolved";
|
|
57
|
+
readonly members: readonly CenterRefMember[];
|
|
58
|
+
} | {
|
|
59
|
+
readonly verdict: "refs_unreadable";
|
|
60
|
+
readonly fault: PlainStringRecordFault;
|
|
61
|
+
readonly member?: string;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* 解析一张中心引用表(见文件头注的律)。`refs` 是名册里 `envRefs` / `headerRefs` 的值(`undefined` = 没声明);`lookup` 是调用方给的查找函数(必给)。
|
|
65
|
+
* 判而不决:`resolved` 之外的两个结论,调用方按自己那条腿的规则处置(例如整台不挂、并点名没解到的成员)。
|
|
66
|
+
* `lookup` 不是函数 ⇒ `TypeError`;别的一切输入都得到一个读数,不抛。
|
|
67
|
+
*/
|
|
68
|
+
export declare function resolveCenterRefs(refs: unknown, lookup: CenterRefLookup): CenterRefsResolution;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 中心引用的**解析口** `resolveCenterRefs(refs, lookup)` —— 纯函数、浏览器安全。
|
|
3
|
+
*
|
|
4
|
+
* 中心名册里 MCP server 的传输带两张**引用表**:stdio 的 `envRefs`(目标 env 键 → 变量名)、http 的 `headerRefs`(目标头名 → 变量名)。名册只存**变量名**、从不存值;
|
|
5
|
+
* 值要在消费方自己的宿主环境里读。此前本包只发引用表的形、不发读它的法:变量没设、设了但读到的不是串、读它的时候抛了,留给每个宿主自己写 —— 各按各的口径处置、各留各的句子。
|
|
6
|
+
* 现在一个读法:**每个成员一句判定,整台一个结论;本口只判,不决定挂不挂**(挂不挂是调用方按自己那条腿的规则定的事)。
|
|
7
|
+
*
|
|
8
|
+
* ## 律(一条)
|
|
9
|
+
* 引用表怎么读、每个变量怎么问、读不出怎么说,只有这一处:
|
|
10
|
+
* · **引用表经公开的记录读器读**({@link readPlainStringRecord}:同一个函数、同一张原因词表 {@link PLAIN_STRING_RECORD_FAULTS}),不另写形状 / 描述符判据。读不出(整张不是纯数据、有成员的变量名不是串、
|
|
11
|
+
* 读成员就抛、叫 `__proto__` 的成员)⇒ 整台结论 `refs_unreadable`,带读器的词,**一个变量都不问** —— 引用表本身不可信时,问了的值也没有可以挂的地方。
|
|
12
|
+
* 读器会把叫 `__proto__` 的串值成员**剔出副本**交其余;本口不许这样悄悄丢一条引用再挂一台少了鉴权的 server,所以同样答 `refs_unreadable`、词 `member_name_reserved`。
|
|
13
|
+
* · **查找函数 `lookup(name)` 由调用方给**,本口**不读 `process.env`**(源里没有 `process` / `globalThis`;宿主怎么读环境、读哪一层环境、要不要缓存,都是调用方的事)。没给 / 不是函数 ⇒ `TypeError`
|
|
14
|
+
* (这是调用方的编程错,不是数据错:不折成成员的 `read_threw`,更不回落任何环境)。
|
|
15
|
+
* · 每个成员恰问一次 `lookup`(按引用表副本的自有键序 —— 整数形的键在前、其余按记录列出的次序;实参只有变量名一个),判成**一个词**({@link CenterRefMemberVerdict}):
|
|
16
|
+
* `resolved`(答了串,空串也是串)· `missing_name`(答 `undefined`:变量没设)· `not_a_string`(答了别的种类 —— 例如对普通对象做 `obj[name]` 读到继承来的函数)· `read_threw`(`lookup` 抛了,不传出)。
|
|
17
|
+
* 一个成员坏了**不早停**:每个成员都有自己的判定,调用方可以一次点名所有缺的变量。同一个变量被两个目标键引用就问两次(缓存是查找函数的事)。
|
|
18
|
+
* · 整台结论只有三个词({@link CenterRefsVerdict}):`resolved`(每个成员都是 `resolved`;零成员也是)· `unresolved`(有成员不是)· `refs_unreadable`(引用表读不出)。
|
|
19
|
+
* **值只在 `resolved` 臂的 `values` 里**;`unresolved` 臂的行只有词,没有任何变量的值(失败读数会进日志)。
|
|
20
|
+
*
|
|
21
|
+
* ## 引用表缺席
|
|
22
|
+
* `refs` 是 `undefined`(这台 server 没声明引用)⇒ `resolved`、零成员、一次都不问 —— 与「空表」同一个答案。别的不是纯数据记录的值(`null`、数组、串……)是 `refs_unreadable`,不当缺席。
|
|
23
|
+
*
|
|
24
|
+
* ## 读数的构造
|
|
25
|
+
* 成员表用 `map`,`values` 用 `Object.defineProperty` + 零原型描述符({@link dataDescriptor})逐键写进一个新对象(定义语义,不走 [[Set]]):`Object.prototype` 上挂了数字键 setter 时目标键 `"0"` 的值也不会被吞;
|
|
26
|
+
* `constructor` / `toString` 这类原型链上的名字作目标键照落成自有数据属性。交出的 `values` 原型是 `Object.prototype`,与读器交出的副本同形。
|
|
27
|
+
*/
|
|
28
|
+
import { PLAIN_STRING_RECORD_RESERVED_MEMBER, readPlainStringRecord } from "./mcp-request-entry.js";
|
|
29
|
+
import { dataDescriptor, inspect } from "./own-key-read.js";
|
|
30
|
+
/** 一个成员没解到的**原因词**,闭集,三个:`missing_name`(查找函数答 `undefined` —— 变量没设)· `not_a_string`(答了别的种类)· `read_threw`(查找函数抛了)。消费方按词分派,穷尽 `switch`,不读句子(本口没有句子)。 */
|
|
31
|
+
export const CENTER_REF_FAULTS = ["missing_name", "not_a_string", "read_threw"];
|
|
32
|
+
/** 整台结论的词,闭集,三个:`resolved` · `unresolved`(有成员没解到)· `refs_unreadable`(引用表读不出)。 */
|
|
33
|
+
export const CENTER_REFS_VERDICTS = ["resolved", "unresolved", "refs_unreadable"];
|
|
34
|
+
/** 问一次 `lookup(ref)` 并判成一个词。抛了 ⇒ `read_threw`(经内核的检视口,不另包 `try`);答 `undefined` ⇒ `missing_name`;答串 ⇒ 解到;答别的 ⇒ `not_a_string`。 */
|
|
35
|
+
function answerOf(ref, lookup) {
|
|
36
|
+
const asked = inspect(() => lookup(ref));
|
|
37
|
+
if (!asked.ok)
|
|
38
|
+
return { verdict: "read_threw" };
|
|
39
|
+
const answer = asked.value;
|
|
40
|
+
if (typeof answer === "string")
|
|
41
|
+
return { verdict: "resolved", value: answer };
|
|
42
|
+
return { verdict: answer === undefined ? "missing_name" : "not_a_string" };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 解析一张中心引用表(见文件头注的律)。`refs` 是名册里 `envRefs` / `headerRefs` 的值(`undefined` = 没声明);`lookup` 是调用方给的查找函数(必给)。
|
|
46
|
+
* 判而不决:`resolved` 之外的两个结论,调用方按自己那条腿的规则处置(例如整台不挂、并点名没解到的成员)。
|
|
47
|
+
* `lookup` 不是函数 ⇒ `TypeError`;别的一切输入都得到一个读数,不抛。
|
|
48
|
+
*/
|
|
49
|
+
export function resolveCenterRefs(refs, lookup) {
|
|
50
|
+
if (typeof lookup !== "function") {
|
|
51
|
+
throw new TypeError("resolveCenterRefs: `lookup` must be a function (variable name => string | undefined) supplied by the caller — this function reads no environment of its own");
|
|
52
|
+
}
|
|
53
|
+
if (refs === undefined)
|
|
54
|
+
return { verdict: "resolved", members: [], values: {} };
|
|
55
|
+
const read = readPlainStringRecord(refs);
|
|
56
|
+
if (!read.ok)
|
|
57
|
+
return { verdict: "refs_unreadable", fault: read.fault, ...(read.member !== undefined ? { member: read.member } : {}) };
|
|
58
|
+
if (read.reservedMember)
|
|
59
|
+
return { verdict: "refs_unreadable", fault: "member_name_reserved", member: PLAIN_STRING_RECORD_RESERVED_MEMBER };
|
|
60
|
+
const judged = Object.entries(read.record).map((pair) => ({ key: pair[0], ref: pair[1], answer: answerOf(pair[1], lookup) }));
|
|
61
|
+
const members = judged.map((j) => ({ key: j.key, ref: j.ref, verdict: j.answer.verdict }));
|
|
62
|
+
if (judged.some((j) => j.answer.verdict !== "resolved"))
|
|
63
|
+
return { verdict: "unresolved", members };
|
|
64
|
+
const values = {};
|
|
65
|
+
for (const j of judged) {
|
|
66
|
+
if (j.answer.verdict === "resolved")
|
|
67
|
+
Object.defineProperty(values, j.key, dataDescriptor(j.answer.value));
|
|
68
|
+
}
|
|
69
|
+
return { verdict: "resolved", members, values };
|
|
70
|
+
}
|
package/dist/config-fns.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { z } from "zod";
|
|
19
19
|
import { MODEL_ENTRY_REFUSAL_CODES, ModelRefusalsWire } from "./fleet.js";
|
|
20
20
|
import { UNINSPECTABLE, dataDescriptor, emptyDocument, guardedSafeParse, inspect, isAccessorDescriptor, isolatedObject, ownEntriesOf, ownRow, ownValueOf } from "./own-key-read.js";
|
|
21
|
+
import { ownEnabledFlag } from "./catalog-manages.js";
|
|
21
22
|
import { isJsonArray, isPlainJsonObject, jsonShapeOf } from "./plain-json-object.js";
|
|
22
23
|
import { carriesUserinfo, secretScanForms } from "./secret-scan.js"; // package-private normalization (both scanners read it) + the userinfo judgement (not in the barrel)
|
|
23
24
|
import { sha256Hex } from "./sha256.js";
|
|
@@ -362,7 +363,8 @@ export function withheldNameToken(name) {
|
|
|
362
363
|
return `withheld:${sha256Hex(`${WITHHELD_NAME_TOKEN_DOMAIN}${JSON.stringify(name)}`).slice(0, 12)}`;
|
|
363
364
|
}
|
|
364
365
|
/** 一条被丢条目原文的身份({@link DroppedEntryIdentity} 的**唯一**读法)。只看纯 JSON 对象的**自有数据
|
|
365
|
-
* 属性**;任何反射/陷阱抛错 ⇒ 当作读不出(身份是诊断面的附件,绝不许把抢救拖下水)
|
|
366
|
+
* 属性**;任何反射/陷阱抛错 ⇒ 当作读不出(身份是诊断面的附件,绝不许把抢救拖下水)。`enabled` 读的是「显式停用」的那一只
|
|
367
|
+
* ({@link ownEnabledFlag},`./catalog-manages`):目录管不管模型面的原文判官与这里同一份读法。 */
|
|
366
368
|
function readEntryIdentity(entry) {
|
|
367
369
|
try {
|
|
368
370
|
if (!isPlainJsonObject(entry))
|
|
@@ -373,11 +375,11 @@ function readEntryIdentity(entry) {
|
|
|
373
375
|
};
|
|
374
376
|
const name = own("name");
|
|
375
377
|
const id = own("id");
|
|
376
|
-
const enabled =
|
|
378
|
+
const enabled = ownEnabledFlag(entry); // 形上面已判过一次(`isPlainJsonObject`):不再经判形的入口问第二次
|
|
377
379
|
const identity = {
|
|
378
380
|
...(typeof name === "string" ? (isDisclosableIdentity(name) ? { name } : { nameWithheld: true, token: withheldNameToken(name) }) : {}),
|
|
379
381
|
...(typeof id === "string" ? (isDisclosableIdentity(id) ? { id } : { idWithheld: true }) : {}),
|
|
380
|
-
...(
|
|
382
|
+
...(enabled === undefined ? {} : { enabled }),
|
|
381
383
|
};
|
|
382
384
|
return Object.keys(identity).length > 0 ? identity : undefined;
|
|
383
385
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置根上的**根级文档**(纯、零 I/O、浏览器安全;读写函数在 `./node`,见 `./config-root-node`)。
|
|
3
|
+
*
|
|
4
|
+
* ```
|
|
5
|
+
* <根>/
|
|
6
|
+
* config.d/… 便携域文档(文件仓库只扫这一个目录)
|
|
7
|
+
* config.env-models.json env 模型表被目录顶替的部署决定 {"envModels":"replaced"|"kept"}
|
|
8
|
+
* config.migration.SS-M11.json 一次性迁移 SS-M11 的运行记录 {"migration":"SS-M11"}(按 id 一份)
|
|
9
|
+
* .env 秘密值(不归本包读)
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* ## 律
|
|
13
|
+
* · **根归客户端(TOC)所有**,引擎在根上只写这两份文档;两份都在 `config.d/` 之外 —— 已核的各版文件仓库都只扫 `<根>/config.d`(抽样,不证全称),
|
|
14
|
+
* 被检视的配置读者不把这两份当域文档、不因它们在场拒配置,所以写者不探引擎版本。
|
|
15
|
+
* 离线预置(引擎在本根起服之前铺好:镜像 / ConfigMap / 还原)时两份文档与域文档可按任意顺序写;运行时写序是定的:写者先写决定、再写 `config.d/models.json`
|
|
16
|
+
* (都在它对 models.json 的那把锁里),迁移先建运行记录、再铸决定。
|
|
17
|
+
* · **一份读法、一张闭集答案表**:决定只有 {@link EnvModelsDecision} 这一种答案(从字节读出的带同一次读的 `digest`,读不到字节的带 `errno`),
|
|
18
|
+
* 读这份决定的三方 —— 引擎(本地配置腿强制这道闸之后;今天引擎还不读它)、一次性迁移 SS-M11、写者的计划助手 —— 吃同一个答案;它的身份(告警去重键、本地 etag 的贡献)只有一种拼法 {@link envModelsDecisionTag}。
|
|
19
|
+
* · **运行记录按 id 一份**:「迁移 X 跑过」⇔ `<根>/config.migration.<X>.json` 在场({@link MigrationMarker});没有聚合文件、没有追加写;
|
|
20
|
+
* 读不懂的记录按「跑过」处置(fail-closed)。记录由迁移以「不存在才建」写:同一个 id 只有一个进程建得成 —— 它是那条迁移在本根上唯一的执行者。
|
|
21
|
+
* · 两份文档的形都是 strict 的:多一个键(含自有 `__proto__`)、拼错的词都读成 `bad_shape`,不剥、不猜;digest 对读到的**字节**求。名字只在这里定一次(引擎的目录信任判官取 {@link CONFIG_ROOT_DOCUMENTS})。
|
|
22
|
+
*/
|
|
23
|
+
import type { OneTimeMigrationId } from "./migration-receipt.js";
|
|
24
|
+
/** 配置根上的 env 模型表决定文档的名字(文件 `<根>/config.env-models.json`)。 */
|
|
25
|
+
export declare const ENV_MODELS_DOCUMENT: "config.env-models";
|
|
26
|
+
/** 运行记录文档名的前缀(包内:名字 = 前缀 + 迁移 id,文件 = 名字 + `.json`)。 */
|
|
27
|
+
declare const MIGRATION_MARKER_PREFIX: "config.migration.";
|
|
28
|
+
/** 一条一次性迁移的运行记录文档名(包内)。 */
|
|
29
|
+
export declare const migrationMarkerDocument: <I extends OneTimeMigrationId>(id: I) => `${typeof MIGRATION_MARKER_PREFIX}${I}`;
|
|
30
|
+
/**
|
|
31
|
+
* 根级文档名单(闭集、序固定;文件 = 名字 + `.json`,与 `config.d` 文档同一拼法):决定文档,与今天唯一会写运行记录的迁移 SS-M11 的那一份。
|
|
32
|
+
* 引擎对配置根的目录信任判官、客户端的持久文件登记都取这一张,不手抄。判法与 `config.d` 里的文档**同一条律**(属主可信、路径上非链接组件不得组 / 他人可写、
|
|
33
|
+
* 常规文件、有大小上界)—— 一条律、两张名单(`config.d` 读回面一张,本张一张);`config.d` 不在时也照判。
|
|
34
|
+
* `<根>/.env` **不在**这张名单:引擎今天的目录信任判官只判 `config.d` 与它的读回面,不判 `.env`(秘密值文件,本包的文件仓库不读它);本名单只登记引擎会读的根级文档。
|
|
35
|
+
*/
|
|
36
|
+
export declare const CONFIG_ROOT_DOCUMENTS: readonly ["config.env-models", "config.migration.SS-M11"];
|
|
37
|
+
export type ConfigRootDocument = (typeof CONFIG_ROOT_DOCUMENTS)[number];
|
|
38
|
+
/** 有运行记录的迁移 id(由 {@link CONFIG_ROOT_DOCUMENTS} 派生:名单里有它的记录文档 ⇔ 它写记录)。 */
|
|
39
|
+
export type MarkedMigrationId = {
|
|
40
|
+
[I in OneTimeMigrationId]: `${typeof MIGRATION_MARKER_PREFIX}${I}` extends ConfigRootDocument ? I : never;
|
|
41
|
+
}[OneTimeMigrationId];
|
|
42
|
+
/** 包内:`id` 是写运行记录的迁移吗(读运行记录之前判:别的 id 没有记录可读,更不许拿它拼路径)。 */
|
|
43
|
+
export declare const isMarkedMigrationId: (id: unknown) => id is MarkedMigrationId;
|
|
44
|
+
/** 决定文档里能写的两个词(闭集):`replaced` = 目录管模型面时顶替 env 模型表;`kept` = 不顶替(与缺席同义,运行记录在场之后)。 */
|
|
45
|
+
export declare const ENV_MODELS_DECISIONS: readonly ["replaced", "kept"];
|
|
46
|
+
export type EnvModelsDecisionWord = (typeof ENV_MODELS_DECISIONS)[number];
|
|
47
|
+
/** 包内:是不是决定词(闭集,判法只此一只;写函数与读法同调)。 */
|
|
48
|
+
export declare const isDecisionWord: (v: unknown) => v is EnvModelsDecisionWord;
|
|
49
|
+
/** 根级文档读不懂的三种原因(闭集):`bad_json` = 空 / 纯空白 / 不是 JSON;`bad_shape` = 是 JSON 但不是这份文档的形;`io` = 字节读不出(权限 —— 可信属主的 0000 文件读恒 `EACCES`,是持久的 —— 不是常规文件、悬空链接、瞬时读错)。 */
|
|
50
|
+
export declare const ROOT_DOCUMENT_UNREADABLE_REASONS: readonly ["bad_json", "bad_shape", "io"];
|
|
51
|
+
export type RootDocumentUnreadableReason = (typeof ROOT_DOCUMENT_UNREADABLE_REASONS)[number];
|
|
52
|
+
/**
|
|
53
|
+
* 决定文档的读数(闭集;三方穷举 `switch`)。`absent` 只在文件不存在时答 —— 读不出的字节是 `unreadable`,不是缺席。
|
|
54
|
+
* 有字节的四臂(`replaced` / `kept` / `unreadable{bad_json|bad_shape}`)带 `digest` = **同一次读**得到的字节的 SHA-256 前 16 位十六进制;
|
|
55
|
+
* 读不到字节的 `unreadable{io}` 带 `errno`(错误上没有 errno ⇒ `uncoded`)。`{` 改成 `[` 这种「答案同为 bad_json、字节不同」的改动 digest 也变。digest 不可逆,不带内容。
|
|
56
|
+
*/
|
|
57
|
+
export type EnvModelsDecision = {
|
|
58
|
+
readonly kind: "absent";
|
|
59
|
+
} | {
|
|
60
|
+
readonly kind: "replaced";
|
|
61
|
+
readonly digest: string;
|
|
62
|
+
} | {
|
|
63
|
+
readonly kind: "kept";
|
|
64
|
+
readonly digest: string;
|
|
65
|
+
} | {
|
|
66
|
+
readonly kind: "unreadable";
|
|
67
|
+
readonly reason: "bad_json" | "bad_shape";
|
|
68
|
+
readonly digest: string;
|
|
69
|
+
} | {
|
|
70
|
+
readonly kind: "unreadable";
|
|
71
|
+
readonly reason: "io";
|
|
72
|
+
readonly errno: string;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* 一份决定读数的**身份**(带标签的一种拼法):`absent` / `d:<digest>`(有字节的四臂)/ `io:<errno>`。引擎读这份决定时(今天引擎还不读),「每份内容只告警一次」的去重键与本地 etag 的贡献都取它 ——
|
|
76
|
+
* 来自同一次读,不再开文件(那就是第二个读者)。
|
|
77
|
+
*/
|
|
78
|
+
export declare function envModelsDecisionTag(decision: EnvModelsDecision): string;
|
|
79
|
+
/** 一份运行记录的读数(闭集):`present` = 那条迁移跑过(或正被它的执行者跑);读不懂的按「跑过」处置(fail-closed)。 */
|
|
80
|
+
export type MigrationMarker = {
|
|
81
|
+
readonly kind: "absent";
|
|
82
|
+
} | {
|
|
83
|
+
readonly kind: "present";
|
|
84
|
+
} | {
|
|
85
|
+
readonly kind: "unreadable";
|
|
86
|
+
readonly reason: RootDocumentUnreadableReason;
|
|
87
|
+
};
|
|
88
|
+
/** 包内:决定文档的**字节读法**(唯一一份;`./config-root-node` 的读者在拿到字节之后调它)—— 字节 → 答案(带这些字节的 digest)。 */
|
|
89
|
+
export declare function readEnvModelsDecisionBytes(bytes: Uint8Array): Exclude<EnvModelsDecision, {
|
|
90
|
+
readonly kind: "absent";
|
|
91
|
+
} | {
|
|
92
|
+
readonly reason: "io";
|
|
93
|
+
}>;
|
|
94
|
+
/** 包内:字节没读到时的决定读数(errno 的拼法只在本文件)。 */
|
|
95
|
+
export declare function ioDecision(errno: string | undefined): Extract<EnvModelsDecision, {
|
|
96
|
+
readonly reason: "io";
|
|
97
|
+
}>;
|
|
98
|
+
/** 包内:运行记录文档的值(`{"migration":<id>}`,写与读同一只形)。 */
|
|
99
|
+
export declare const migrationMarkerValue: (id: MarkedMigrationId) => {
|
|
100
|
+
readonly migration: MarkedMigrationId;
|
|
101
|
+
};
|
|
102
|
+
/** 包内:运行记录的**字节读法**(唯一一份):`{"migration":<这条记录的 id>}` strict ⇒ `present`;别的 ⇒ 读不懂(id 对不上也是坏形)。 */
|
|
103
|
+
export declare function readMigrationMarkerBytes(id: MarkedMigrationId, bytes: Uint8Array): MigrationMarker;
|
|
104
|
+
/** 包内:字节 → 文本(`config.d/models.json` 的读也经它,与上面两份同一解码)。 */
|
|
105
|
+
export declare const decodeRootBytes: (bytes: Uint8Array) => string;
|
|
106
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { isPlainJsonObject } from "./plain-json-object.js";
|
|
2
|
+
import { sha256Digest, toHex } from "./sha256-core.js";
|
|
3
|
+
/** 配置根上的 env 模型表决定文档的名字(文件 `<根>/config.env-models.json`)。 */
|
|
4
|
+
export const ENV_MODELS_DOCUMENT = "config.env-models";
|
|
5
|
+
/** 运行记录文档名的前缀(包内:名字 = 前缀 + 迁移 id,文件 = 名字 + `.json`)。 */
|
|
6
|
+
const MIGRATION_MARKER_PREFIX = "config.migration.";
|
|
7
|
+
/** 一条一次性迁移的运行记录文档名(包内)。 */
|
|
8
|
+
export const migrationMarkerDocument = (id) => `${MIGRATION_MARKER_PREFIX}${id}`;
|
|
9
|
+
/**
|
|
10
|
+
* 根级文档名单(闭集、序固定;文件 = 名字 + `.json`,与 `config.d` 文档同一拼法):决定文档,与今天唯一会写运行记录的迁移 SS-M11 的那一份。
|
|
11
|
+
* 引擎对配置根的目录信任判官、客户端的持久文件登记都取这一张,不手抄。判法与 `config.d` 里的文档**同一条律**(属主可信、路径上非链接组件不得组 / 他人可写、
|
|
12
|
+
* 常规文件、有大小上界)—— 一条律、两张名单(`config.d` 读回面一张,本张一张);`config.d` 不在时也照判。
|
|
13
|
+
* `<根>/.env` **不在**这张名单:引擎今天的目录信任判官只判 `config.d` 与它的读回面,不判 `.env`(秘密值文件,本包的文件仓库不读它);本名单只登记引擎会读的根级文档。
|
|
14
|
+
*/
|
|
15
|
+
export const CONFIG_ROOT_DOCUMENTS = [ENV_MODELS_DOCUMENT, migrationMarkerDocument("SS-M11")];
|
|
16
|
+
/** 包内:`id` 是写运行记录的迁移吗(读运行记录之前判:别的 id 没有记录可读,更不许拿它拼路径)。 */
|
|
17
|
+
export const isMarkedMigrationId = (id) => typeof id === "string" && CONFIG_ROOT_DOCUMENTS.includes(`${MIGRATION_MARKER_PREFIX}${id}`);
|
|
18
|
+
/** 决定文档里能写的两个词(闭集):`replaced` = 目录管模型面时顶替 env 模型表;`kept` = 不顶替(与缺席同义,运行记录在场之后)。 */
|
|
19
|
+
export const ENV_MODELS_DECISIONS = ["replaced", "kept"];
|
|
20
|
+
/** 包内:是不是决定词(闭集,判法只此一只;写函数与读法同调)。 */
|
|
21
|
+
export const isDecisionWord = (v) => ENV_MODELS_DECISIONS.includes(v);
|
|
22
|
+
/** 根级文档读不懂的三种原因(闭集):`bad_json` = 空 / 纯空白 / 不是 JSON;`bad_shape` = 是 JSON 但不是这份文档的形;`io` = 字节读不出(权限 —— 可信属主的 0000 文件读恒 `EACCES`,是持久的 —— 不是常规文件、悬空链接、瞬时读错)。 */
|
|
23
|
+
export const ROOT_DOCUMENT_UNREADABLE_REASONS = ["bad_json", "bad_shape", "io"];
|
|
24
|
+
/**
|
|
25
|
+
* 一份决定读数的**身份**(带标签的一种拼法):`absent` / `d:<digest>`(有字节的四臂)/ `io:<errno>`。引擎读这份决定时(今天引擎还不读),「每份内容只告警一次」的去重键与本地 etag 的贡献都取它 ——
|
|
26
|
+
* 来自同一次读,不再开文件(那就是第二个读者)。
|
|
27
|
+
*/
|
|
28
|
+
export function envModelsDecisionTag(decision) {
|
|
29
|
+
switch (decision.kind) {
|
|
30
|
+
case "absent":
|
|
31
|
+
return "absent";
|
|
32
|
+
case "replaced":
|
|
33
|
+
case "kept":
|
|
34
|
+
return `d:${decision.digest}`;
|
|
35
|
+
case "unreadable":
|
|
36
|
+
return decision.reason === "io" ? `io:${decision.errno}` : `d:${decision.digest}`;
|
|
37
|
+
default: {
|
|
38
|
+
const unhandled = decision;
|
|
39
|
+
throw new Error(`envModelsDecisionTag: unhandled decision ${JSON.stringify(unhandled)}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** 同一次读得到的**字节**的 digest(见 {@link EnvModelsDecision};对字节求,不对解码后的文本 —— 非法 UTF-8 解码成 U+FFFD 之后不同的字节会同 digest)。 */
|
|
44
|
+
const digestOf = (bytes) => toHex(sha256Digest(bytes)).slice(0, 16);
|
|
45
|
+
/** 字节 → 文本(UTF-8;非法序列换 U+FFFD;BOM 留在文本里,由 {@link parseJsonText} 的 trim 去掉 —— 与 `config.d` 文档的文本读法同一结果)。 */
|
|
46
|
+
const textOf = (bytes) => new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes);
|
|
47
|
+
/**
|
|
48
|
+
* 一个 JSON 值若是「恰好这一个自有键」的纯对象 ⇒ 那个键的值;否则(多一个键 —— 含 `JSON.parse` 铸出的自有 `__proto__` 键 —— 少一个键、不是纯对象)⇒ `undefined`。
|
|
49
|
+
* 两份文档的形都是 strict 的,判在这里一处;不交给 zod 的 strict:它对自有 `__proto__` 键的处置随版本变(4.4.x 跳过它、4.6.x 拒它),本包的 zod 区间两端答案会不同。
|
|
50
|
+
* 文档是 `JSON.parse` 的产物,自有键都是可枚举数据属性,`Object.keys` 就是它的全部键。
|
|
51
|
+
*/
|
|
52
|
+
function soleOwnValue(v, key) {
|
|
53
|
+
if (!isPlainJsonObject(v))
|
|
54
|
+
return undefined;
|
|
55
|
+
const keys = Object.keys(v);
|
|
56
|
+
return keys.length === 1 && keys[0] === key ? v[key] : undefined;
|
|
57
|
+
}
|
|
58
|
+
/** 文本 → JSON 值:空 / 纯空白 / 坏 JSON ⇒ 读不成(一份被截断的文件不是缺席)。 */
|
|
59
|
+
function parseJsonText(text) {
|
|
60
|
+
const trimmed = text.trim();
|
|
61
|
+
if (!trimmed)
|
|
62
|
+
return { ok: false };
|
|
63
|
+
try {
|
|
64
|
+
return { ok: true, value: JSON.parse(trimmed) };
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return { ok: false };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** 包内:决定文档的**字节读法**(唯一一份;`./config-root-node` 的读者在拿到字节之后调它)—— 字节 → 答案(带这些字节的 digest)。 */
|
|
71
|
+
export function readEnvModelsDecisionBytes(bytes) {
|
|
72
|
+
const digest = digestOf(bytes);
|
|
73
|
+
const json = parseJsonText(textOf(bytes));
|
|
74
|
+
if (!json.ok)
|
|
75
|
+
return { kind: "unreadable", reason: "bad_json", digest };
|
|
76
|
+
const word = soleOwnValue(json.value, "envModels");
|
|
77
|
+
return isDecisionWord(word) ? { kind: word, digest } : { kind: "unreadable", reason: "bad_shape", digest };
|
|
78
|
+
}
|
|
79
|
+
/** 包内:字节没读到时的决定读数(errno 的拼法只在本文件)。 */
|
|
80
|
+
export function ioDecision(errno) {
|
|
81
|
+
return { kind: "unreadable", reason: "io", errno: errno ?? "uncoded" };
|
|
82
|
+
}
|
|
83
|
+
/** 包内:运行记录文档的值(`{"migration":<id>}`,写与读同一只形)。 */
|
|
84
|
+
export const migrationMarkerValue = (id) => ({ migration: id });
|
|
85
|
+
/** 包内:运行记录的**字节读法**(唯一一份):`{"migration":<这条记录的 id>}` strict ⇒ `present`;别的 ⇒ 读不懂(id 对不上也是坏形)。 */
|
|
86
|
+
export function readMigrationMarkerBytes(id, bytes) {
|
|
87
|
+
const json = parseJsonText(textOf(bytes));
|
|
88
|
+
if (!json.ok)
|
|
89
|
+
return { kind: "unreadable", reason: "bad_json" };
|
|
90
|
+
return soleOwnValue(json.value, "migration") === id ? { kind: "present" } : { kind: "unreadable", reason: "bad_shape" };
|
|
91
|
+
}
|
|
92
|
+
/** 包内:字节 → 文本(`config.d/models.json` 的读也经它,与上面两份同一解码)。 */
|
|
93
|
+
export const decodeRootBytes = textOf;
|