@sema-agent/settings-schema 15.1.0 → 16.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 +226 -0
- package/README.md +60 -46
- package/conformance/VERSION +1 -1
- package/conformance/cases/c16-deep-nesting-205/expected.json +2 -2
- package/conformance/cases/c16-deep-nesting-205/meta.json +1 -1
- package/conformance/cases/c50-s784-lane-host-legal/expected.json +6 -2
- package/conformance/cases/c50-s784-lane-host-legal/input.json +3 -1
- package/conformance/cases/c51-s784-lane-e2b-legal/expected.json +6 -2
- package/conformance/cases/c51-s784-lane-e2b-legal/input.json +3 -1
- package/conformance/cases/c52-s784-lane-k8s-legal/expected.json +6 -2
- package/conformance/cases/c52-s784-lane-k8s-legal/input.json +3 -1
- package/conformance/cases/c53-s784-lane-ssh-legal/expected.json +6 -2
- package/conformance/cases/c53-s784-lane-ssh-legal/input.json +3 -1
- package/conformance/cases/c54-s784-lane-adb-legal/expected.json +6 -2
- package/conformance/cases/c54-s784-lane-adb-legal/input.json +3 -1
- package/conformance/cases/c55-s784-lane-local-docker-legal/expected.json +6 -2
- package/conformance/cases/c55-s784-lane-local-docker-legal/input.json +3 -1
- package/conformance/cases/c56-s784-lane-device-legal/expected.json +8 -2
- package/conformance/cases/c56-s784-lane-device-legal/input.json +5 -2
- package/conformance/cases/c57-s784-g1-enabled-no-lane/meta.json +1 -1
- package/conformance/cases/c58-s784-g2-multitenant-host/input.json +3 -1
- package/conformance/cases/c59-s784-g3-k8s-missing-image/input.json +3 -1
- package/conformance/cases/c60-s784-g3-e2b-missing-key/input.json +3 -1
- package/conformance/cases/c61-s784-g3-other-lane-key/input.json +3 -1
- package/conformance/cases/c62-s784-g7a-session-tidb/input.json +3 -1
- package/conformance/cases/c63-s784-host-two-replicas/input.json +3 -1
- package/conformance/cases/c64-s784-multitenant-local-store/input.json +3 -1
- package/conformance/cases/c65-s784-pg-without-db/input.json +3 -1
- package/conformance/cases/c66-s784-memory-backend-mismatch/input.json +3 -1
- package/conformance/cases/c66-s784-memory-backend-mismatch/meta.json +1 -1
- package/conformance/cases/c68-s784-device-local-store/input.json +6 -1
- package/conformance/cases/cli04-env-value-with-newline/expected.json +6 -2
- package/conformance/cases/cli04-env-value-with-newline/input.json +3 -1
- package/dist/cli.d.ts +1 -1
- package/dist/config-d-file.d.ts +41 -5
- package/dist/config-d-file.js +105 -22
- package/dist/config-document-refusal.d.ts +39 -0
- package/dist/config-document-refusal.js +2 -0
- package/dist/device-relay.d.ts +21 -0
- package/dist/device-relay.js +23 -0
- package/dist/env-model-table.d.ts +2 -9
- package/dist/env-model-table.js +6 -14
- package/dist/execution-lane-index.d.ts +21 -17
- package/dist/execution-lane-index.js +28 -20
- package/dist/execution-lanes.d.ts +20 -5
- package/dist/execution-lanes.js +31 -7
- package/dist/file-store.d.ts +30 -3
- package/dist/file-store.js +89 -32
- package/dist/fleet.d.ts +6 -8
- package/dist/fleet.js +5 -7
- package/dist/hooks.d.ts +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -3
- package/dist/migration-receipt.d.ts +1 -1
- package/dist/migration-receipt.js +1 -1
- package/dist/one-time-migrations.d.ts +1 -1
- package/dist/one-time-migrations.js +166 -45
- package/dist/remote-exec.js +11 -4
- package/dist/safety-merge-spec.d.ts +1 -1
- package/dist/safety-merge-spec.js +1 -1
- package/dist/types.d.ts +15 -11
- package/dist/types.js +100 -49
- package/dist/url-shape.d.ts +18 -0
- package/dist/url-shape.js +22 -0
- package/docs/API-SURFACE.md +18 -7
- package/docs/CONFORMANCE.md +1 -7
- package/docs/CONSUMER-INTEGRATION.md +4 -4
- package/llms-full.txt +27 -25
- package/llms.txt +10 -8
- package/package.json +1 -1
|
@@ -66,7 +66,9 @@
|
|
|
66
66
|
"DOCKER_HOST": "tcp://docker.internal:2376",
|
|
67
67
|
"DOCKER_IMAGE": "registry.internal/sandbox:1"
|
|
68
68
|
},
|
|
69
|
-
"
|
|
69
|
+
"lanes": [
|
|
70
|
+
"local-docker"
|
|
71
|
+
],
|
|
70
72
|
"secretRefs": {}
|
|
71
73
|
},
|
|
72
74
|
"rateLimitRpm": 0,
|
|
@@ -110,7 +112,9 @@
|
|
|
110
112
|
"DOCKER_HOST": "tcp://docker.internal:2376",
|
|
111
113
|
"DOCKER_IMAGE": "registry.internal/sandbox:1"
|
|
112
114
|
},
|
|
113
|
-
"
|
|
115
|
+
"lanes": [
|
|
116
|
+
"local-docker"
|
|
117
|
+
],
|
|
114
118
|
"secretRefs": {}
|
|
115
119
|
},
|
|
116
120
|
"rateLimitRpm": 0,
|
|
@@ -63,9 +63,12 @@
|
|
|
63
63
|
"runtime": {
|
|
64
64
|
"execution": {
|
|
65
65
|
"coords": {
|
|
66
|
+
"DEVICE_RELAY_URL": "none",
|
|
66
67
|
"DEVICE_SUPERSEDE": "deny"
|
|
67
68
|
},
|
|
68
|
-
"
|
|
69
|
+
"lanes": [
|
|
70
|
+
"device"
|
|
71
|
+
],
|
|
69
72
|
"secretRefs": {}
|
|
70
73
|
},
|
|
71
74
|
"rateLimitRpm": 0,
|
|
@@ -113,9 +116,12 @@
|
|
|
113
116
|
"runtime": {
|
|
114
117
|
"execution": {
|
|
115
118
|
"coords": {
|
|
119
|
+
"DEVICE_RELAY_URL": "none",
|
|
116
120
|
"DEVICE_SUPERSEDE": "deny"
|
|
117
121
|
},
|
|
118
|
-
"
|
|
122
|
+
"lanes": [
|
|
123
|
+
"device"
|
|
124
|
+
],
|
|
119
125
|
"secretRefs": {}
|
|
120
126
|
},
|
|
121
127
|
"rateLimitRpm": 0,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"contributor": "contract",
|
|
3
|
-
"claim": "G1:enabled 的 worker 没有 runtime.execution ⇒ 拒(拒因点名 runtime.execution.
|
|
3
|
+
"claim": "G1:enabled 的 worker 没有 runtime.execution ⇒ 拒(拒因点名 runtime.execution.lanes —— 16.0.0 起车道集)。打「声明不了车道也能过闸、起服落进程内 stub、无手照回 completed」那条静默路(WA-49)。",
|
|
4
4
|
"firstRedOn": []
|
|
5
5
|
}
|
|
@@ -67,7 +67,9 @@
|
|
|
67
67
|
"runtime": {
|
|
68
68
|
"execution": {
|
|
69
69
|
"coords": {},
|
|
70
|
-
"
|
|
70
|
+
"lanes": [
|
|
71
|
+
"host"
|
|
72
|
+
],
|
|
71
73
|
"secretRefs": {}
|
|
72
74
|
},
|
|
73
75
|
"rateLimitRpm": 0,
|
|
@@ -112,7 +114,9 @@
|
|
|
112
114
|
"runtime": {
|
|
113
115
|
"execution": {
|
|
114
116
|
"coords": {},
|
|
115
|
-
"
|
|
117
|
+
"lanes": [
|
|
118
|
+
"host"
|
|
119
|
+
],
|
|
116
120
|
"secretRefs": {}
|
|
117
121
|
},
|
|
118
122
|
"rateLimitRpm": 0,
|
package/dist/cli.d.ts
CHANGED
|
@@ -35,5 +35,5 @@ export interface SettingsSchemaCliIo extends ConfigDirectoryIo {
|
|
|
35
35
|
stderr(text: string): void;
|
|
36
36
|
}
|
|
37
37
|
/** 命令行的覆盖面:全部配置域、retired 域(便携与否不论 —— 离线的 `workers.json` 一样迁)与 `remote-exec.json`。 */
|
|
38
|
-
export declare const COVERED_DOCUMENTS: ("models" | "rosters" | "scenarios" | "skills" | "execution" | "plugins" | "systems" | "limits" | "runtime" | "workers" | "hosts" | "projects" | "mcp" | "collab" | "governance" | "entitlement" | "prompts"
|
|
38
|
+
export declare const COVERED_DOCUMENTS: ("remote-exec" | "models" | "rosters" | "scenarios" | "skills" | "execution" | "plugins" | "systems" | "limits" | "runtime" | "workers" | "hosts" | "projects" | "mcp" | "collab" | "governance" | "entitlement" | "prompts")[];
|
|
39
39
|
export declare function runSettingsSchemaCli(argv: readonly string[], io: SettingsSchemaCliIo): Promise<number>;
|
package/dist/config-d-file.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConfigDocumentRefusalReason } from "./config-document-refusal.js";
|
|
1
2
|
import { REMOTE_EXEC_DOCUMENT, RemoteExecSpec } from "./remote-exec.js";
|
|
2
3
|
import { type DomainName, type RetiredDomainName } from "./types.js";
|
|
3
4
|
/** 一份 `config.d` 文档写盘的**文本形** —— 两格缩进的 JSON 加一个换行。写域文件(`writeDomainFile`)、写 `remote-exec.json`(`writeRemoteExec`)与目录迁移写回
|
|
@@ -10,16 +11,51 @@ export declare function parseRemoteExecDocument(raw: unknown): RemoteExecSpec;
|
|
|
10
11
|
export declare function configDPath(root: string, name: string): string;
|
|
11
12
|
/** `config.d` 里能放的文档名:配置域、retired 域、`remote-exec`(`config.d/remote-exec.json`,不是配置域)。 */
|
|
12
13
|
export type ConfigDDocumentName = DomainName | RetiredDomainName | typeof REMOTE_EXEC_DOCUMENT;
|
|
13
|
-
/**
|
|
14
|
-
|
|
14
|
+
/** 文件层的拒因(闭集词里除墓碑外的五个)。 */
|
|
15
|
+
export type ConfigDFileReason = Exclude<ConfigDocumentRefusalReason, "retired_domain">;
|
|
16
|
+
/** 一份在场文档被拒的读数:`reason` 闭集词 · `error` 整句 · `thrown` 旧抛出口抛的那个对象(渲染成抛时原样抛它)· `fingerprint` 这份坏文档的指纹(版本哈希里的坏文件哨兵用它:
|
|
17
|
+
* 文本路径取 trim 后原文的哈希 —— 不同坏内容不同数;读不出文本的 `io` 取错误文案的哈希)。 */
|
|
18
|
+
export interface ConfigDRefused {
|
|
19
|
+
readonly status: "refused";
|
|
20
|
+
readonly reason: ConfigDFileReason;
|
|
21
|
+
readonly error: string;
|
|
22
|
+
readonly thrown: unknown;
|
|
23
|
+
readonly fingerprint: string;
|
|
24
|
+
}
|
|
25
|
+
/** 一份 `config.d` 文档的读数:缺席(ENOENT)· 在场且是一个 JSON 对象 · 被拒({@link ConfigDRefused})。 */
|
|
26
|
+
export type ConfigDReading = {
|
|
27
|
+
readonly status: "absent";
|
|
28
|
+
} | {
|
|
29
|
+
readonly status: "present";
|
|
30
|
+
readonly value: Record<string, unknown>;
|
|
31
|
+
} | ConfigDRefused;
|
|
32
|
+
/** 一个 `config.d/<名>.json` 在场时的**文本** → 读数(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 在场;空 / 纯空白、坏 JSON、
|
|
33
|
+
* 不是对象、嵌得过深({@link MAX_CONFIG_D_DOCUMENT_DEPTH})⇒ 被拒,每种一个词一句话(点名 `file` —— 调用方读的那份文件的真实路径 —— 与形,句尾 {@link documentWayOut})。 */
|
|
34
|
+
export declare function judgeConfigDTextReading(name: ConfigDDocumentName, text: string, file: string): Exclude<ConfigDReading, {
|
|
35
|
+
readonly status: "absent";
|
|
36
|
+
}>;
|
|
37
|
+
/** 抛出口:一个 `config.d/<名>.json` 在场时的**文本** → 它的值:读数是被拒 ⇒ 抛它的 `thrown`(空 / 坏 JSON / 不是对象各一句)。读盘的入口(`readConfigDFile`、`loadRemoteExec`)
|
|
15
38
|
* 与自己读文本的命令行(`migrate`)都过它 —— 一种坏形一句话,不论是哪份文档。 */
|
|
16
39
|
export declare function judgeConfigDText(name: ConfigDDocumentName, text: string, file: string): Record<string, unknown>;
|
|
17
|
-
/** 一份**已解析的**存量文档在场时的值(同步口 `migrateConfigDocuments` 收的是各层的文档原文,不是文件文本)→
|
|
18
|
-
*
|
|
40
|
+
/** 一份**已解析的**存量文档在场时的值(同步口 `migrateConfigDocuments` 收的是各层的文档原文,不是文件文本)→ 它自己:一个嵌得不过深的 JSON 对象 ⇒ 它;`null` / 数组 / 数 / 串 / 布尔 /
|
|
41
|
+
* 非朴素对象、嵌得过深 ⇒ 抛。{@link judgeConfigDText} 的最后一道判就是这只(文本解析出来之后过它),所以「不是对象」「过深」各一种坏形一句话,不论文档是从文件文本来的还是调用方直接给的;
|
|
19
42
|
* `label` = 句首的称呼(文本路径给文件的真实路径,值路径给 `the <名> document`)。 */
|
|
20
43
|
export declare function judgeConfigDValue(name: ConfigDDocumentName, value: unknown, label: string): Record<string, unknown>;
|
|
21
|
-
/** 读一个 `config.d/<名>.json
|
|
44
|
+
/** 读一个 `config.d/<名>.json` → 读数:不存在(ENOENT)⇒ 缺席;在场 ⇒ {@link judgeConfigDTextReading};在场却读不出(非 ENOENT 的 fs 错误)⇒ 被拒,词 `io`。 */
|
|
45
|
+
export declare function readConfigDFileReading(root: string, name: DomainName | RetiredDomainName): Promise<ConfigDReading>;
|
|
46
|
+
/** 抛出口:读一个 `config.d/<名>.json`:不存在(ENOENT)⇒ `undefined`;在场 ⇒ 它的值;被拒 ⇒ 抛读数的 `thrown`(见 {@link readConfigDFileReading})。 */
|
|
22
47
|
export declare function readConfigDFile(root: string, name: DomainName | RetiredDomainName): Promise<Record<string, unknown> | undefined>;
|
|
48
|
+
/** 一棵 `config.d` 的原文快照:便携域与 retired 域的文档原文(缺席 ⇒ `undefined`),版本哈希的入参。 */
|
|
49
|
+
export type ConfigDRaws = Partial<Record<DomainName | RetiredDomainName, unknown>>;
|
|
50
|
+
/** 一棵 `config.d` 原文快照的内容版本(稳定的 u53 整数):同样的文档 ⇒ 同一个数。`FileConfigStore` 的 `getVersion()` / `getEffective()` / `read()` 读同一只。 */
|
|
51
|
+
export declare function configDVersionOf(raws: ConfigDRaws): number;
|
|
52
|
+
/** 一份被拒文档在版本哈希里的哨兵:`{ __corruptDomainFile: <坏文档的指纹> }` —— 绝不与「文件缺席」同字节(`stableStringify` 把 `undefined` 与 `null` 折成同样的字节,
|
|
53
|
+
* 一份空 / 坏文件若读成缺席,会与「域缺席」同哈希,按版本轮询的消费方就漏掉一次变化),而且不同的坏内容换数:坏→修好、好→坏、坏内容 A→坏内容 B 都换版本。 */
|
|
54
|
+
export declare function corruptDocumentRaw(refused: ConfigDRefused): {
|
|
55
|
+
readonly __corruptDomainFile: string;
|
|
56
|
+
};
|
|
57
|
+
/** retired 域名(`RETIRED_DOMAINS` 的键)—— 读它们的文件只为墓碑,次序 = 墓碑表的声明次序。 */
|
|
58
|
+
export declare const RETIRED_DOMAIN_NAMES: readonly RetiredDomainName[];
|
|
23
59
|
/**
|
|
24
60
|
* `config.d/` 里每个 **retired 域**的文件过一次墓碑(`refuseRetiredDomain`,与 `buildEffective` / `parseDomain` 同一只判官、同一句):带事实 ⇒
|
|
25
61
|
* `RetiredDomainError`;缺席 / `{}` ⇒ 交回读到的原文(调用方要它进版本 hash)。读 `config.d/` 的**每个入口**都先过它 —— `FileConfigStore`
|
package/dist/config-d-file.js
CHANGED
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
* 同一种文件形同一个答案、同一句话(12.0.0 起 `remote-exec.json` 也是这一只;此前它有自己的一只文本判,只拒空与坏 JSON,非对象在命令行上被当成「无事可做」)。
|
|
4
4
|
* 分两只:纯判 `judgeConfigDText`(文本 → 值 | 抛,零 I/O;命令行与 `loadRemoteExec` 自己读文本,只调它)与读盘的 `readConfigDFile`(调它);路径与名字的判只在 `configDPath`。
|
|
5
5
|
*
|
|
6
|
+
* **一次读法,两种渲染**(15.2.0):内核交**结构化读数**({@link ConfigDReading}:缺席 / 在场 / 被拒 + 闭集词 + 整句 + 抛出物 + 指纹)——
|
|
7
|
+
* `judgeConfigDTextReading`(文本 → 读数)与 `readConfigDFileReading`(文件 → 读数)是判据的唯一一处;抛出口 `judgeConfigDText` / `readConfigDFile` 把被拒的读数渲染成「抛它的 `thrown`」
|
|
8
|
+
* (原来抛的那个对象、那句话,io 是原样的 fs 错误),单读口 `FileConfigStore.read()`(16.0.0)把整个目录的读数渲染成 effective 或类型化的拒单。两种渲染读同一份判据,不会各说各话。
|
|
9
|
+
*
|
|
6
10
|
* 律:**只有不存在(ENOENT)= 缺席**(`undefined`);在场的文件必须是一个 JSON 对象。空 / 纯空白(半写、截断)、坏 JSON、解析出来不是对象
|
|
7
|
-
* (`null` / 数组 / 数 / 串 / 布尔)一律是**坏文件** ⇒ 抛(fail-closed:把一个被截断的文件读成缺席,就是静默丢掉那一个域 —— 例:截断的
|
|
11
|
+
* (`null` / 数组 / 数 / 串 / 布尔)、嵌得比整棵目录的内容版本能算的还深(16.0.0)一律是**坏文件** ⇒ 抛(fail-closed:把一个被截断的文件读成缺席,就是静默丢掉那一个域 —— 例:截断的
|
|
8
12
|
* governance.json 丢掉它的护栏;截断的 remote-exec.json 把隔离车道降成宿主车道)。三种坏形每种一句,点名**调用方寻址这份文档用的路径**与形(读的人给路径,
|
|
9
13
|
* 判官不拼 `config.d/` 字面 —— 多个目录各有一份时,句子认得出是哪个 scope 的哪一份;文档是符号链接时点名链接这一端:句尾「delete it」删的是这个 scope 里的这一项,不是被几个 scope 共用的目标)、
|
|
10
14
|
* 句尾出路按文档类({@link documentWayOut}),从不回显文件原文。
|
|
@@ -12,7 +16,8 @@
|
|
|
12
16
|
import { readFile } from "node:fs/promises";
|
|
13
17
|
import path from "node:path";
|
|
14
18
|
import { refuseRetiredDomain, sanitizeZodError } from "./config-fns.js";
|
|
15
|
-
import {
|
|
19
|
+
import { MAX_HASH_DEPTH, stableHash } from "./hash.js";
|
|
20
|
+
import { UNINSPECTABLE, guardedSafeParse, ownElementsOf, ownEntriesOf } from "./own-key-read.js";
|
|
16
21
|
import { jsonShapeOf } from "./plain-json-object.js";
|
|
17
22
|
import { REMOTE_EXEC_DOCUMENT, RemoteExecSpec } from "./remote-exec.js";
|
|
18
23
|
import { RETIRED_DOMAINS, isDomainName, isRetiredDomainName } from "./types.js";
|
|
@@ -41,29 +46,90 @@ export function configDPath(root, name) {
|
|
|
41
46
|
function documentWayOut(name) {
|
|
42
47
|
return name === REMOTE_EXEC_DOCUMENT ? "delete it for the host default, or write a valid remoteExec spec" : "delete it for the domain default, or write a valid object";
|
|
43
48
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*
|
|
47
|
-
|
|
49
|
+
const messageOf = (thrown) => (thrown instanceof Error ? thrown.message : String(thrown));
|
|
50
|
+
/** 一份文档在内容版本的哈希里比快照的根深一层(快照 = `{ <文档名>: 文档 }`),所以文档自己最深嵌 {@link MAX_HASH_DEPTH} − 1 层(文档的根 = 第 0 层)——
|
|
51
|
+
* 再深一层,整棵目录的版本就算不出(16.0.0 之前它在算版本那一步抛,把已经收集的拒一并丢掉;现在它是这一份文档的文件层拒,别的拒照列)。 */
|
|
52
|
+
const MAX_CONFIG_D_DOCUMENT_DEPTH = MAX_HASH_DEPTH - 1;
|
|
53
|
+
/** 一份文档是否嵌得比 `limit` 深(根 = 第 0 层;迭代、遇超即停 —— 多深的文档都不会把调用栈压爆)。成员经包的自有键读法取(数据属性取值,访问器调它自己的 getter 一次,不问代理陷阱);
|
|
54
|
+
* 读不出的容器不往下量 —— 它不是「过深」,是读不出,由之后读它的那一步响亮拒(版本哈希、迁移的快照都对它抛)。 */
|
|
55
|
+
function nestsDeeperThan(value, limit) {
|
|
56
|
+
const stack = [[value, 0]];
|
|
57
|
+
const descend = (members, depth) => {
|
|
58
|
+
if (members.ok)
|
|
59
|
+
for (const [, child] of members.value)
|
|
60
|
+
stack.push([child, depth + 1]);
|
|
61
|
+
};
|
|
62
|
+
for (let top = stack.pop(); top !== undefined; top = stack.pop()) {
|
|
63
|
+
const [node, depth] = top;
|
|
64
|
+
if (depth > limit)
|
|
65
|
+
return true;
|
|
66
|
+
// 往下量的容器与版本哈希往下走的同一组(对象 —— 朴素或不朴素,函数除外 —— 与数组);叶子与读不出的不往下。
|
|
67
|
+
const shape = jsonShapeOf(node);
|
|
68
|
+
switch (shape) {
|
|
69
|
+
case "plain":
|
|
70
|
+
case "non_plain_object":
|
|
71
|
+
if (typeof node !== "function")
|
|
72
|
+
descend(ownEntriesOf(node), depth);
|
|
73
|
+
break;
|
|
74
|
+
case "array":
|
|
75
|
+
descend(ownElementsOf(node), depth);
|
|
76
|
+
break;
|
|
77
|
+
case "null":
|
|
78
|
+
case "primitive":
|
|
79
|
+
case "uninspectable":
|
|
80
|
+
break;
|
|
81
|
+
default: {
|
|
82
|
+
const unhandled = shape;
|
|
83
|
+
throw new Error(`config.d: unhandled shape ${String(unhandled)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
/** 一个 `config.d/<名>.json` 在场时的**文本** → 读数(纯判,零 I/O;见文件头的律):一个 JSON 对象 ⇒ 在场;空 / 纯空白、坏 JSON、
|
|
90
|
+
* 不是对象、嵌得过深({@link MAX_CONFIG_D_DOCUMENT_DEPTH})⇒ 被拒,每种一个词一句话(点名 `file` —— 调用方读的那份文件的真实路径 —— 与形,句尾 {@link documentWayOut})。 */
|
|
91
|
+
export function judgeConfigDTextReading(name, text, file) {
|
|
48
92
|
const trimmed = text.trim();
|
|
93
|
+
const refuse = (reason, thrown) => ({ status: "refused", reason, error: thrown.message, thrown, fingerprint: stableHash(trimmed) });
|
|
49
94
|
if (!trimmed)
|
|
50
|
-
|
|
95
|
+
return refuse("empty", new Error(`${file} is empty/whitespace — ${documentWayOut(name)}`));
|
|
51
96
|
let parsed;
|
|
52
97
|
try {
|
|
53
98
|
parsed = JSON.parse(trimmed);
|
|
54
99
|
}
|
|
55
100
|
catch {
|
|
56
|
-
|
|
101
|
+
return refuse("invalid_json", new Error(`invalid JSON in ${file} — ${documentWayOut(name)}`)); // sanitized: never echo the raw file text into the error
|
|
57
102
|
}
|
|
58
|
-
|
|
103
|
+
const flaw = configDValueFlaw(name, parsed, file);
|
|
104
|
+
return flaw === undefined ? { status: "present", value: parsed } : refuse(flaw.reason, flaw.error);
|
|
105
|
+
}
|
|
106
|
+
/** 抛出口:一个 `config.d/<名>.json` 在场时的**文本** → 它的值:读数是被拒 ⇒ 抛它的 `thrown`(空 / 坏 JSON / 不是对象各一句)。读盘的入口(`readConfigDFile`、`loadRemoteExec`)
|
|
107
|
+
* 与自己读文本的命令行(`migrate`)都过它 —— 一种坏形一句话,不论是哪份文档。 */
|
|
108
|
+
export function judgeConfigDText(name, text, file) {
|
|
109
|
+
const reading = judgeConfigDTextReading(name, text, file);
|
|
110
|
+
if (reading.status === "refused")
|
|
111
|
+
throw reading.thrown;
|
|
112
|
+
return reading.value;
|
|
59
113
|
}
|
|
60
|
-
/** 一份**已解析的**存量文档在场时的值(同步口 `migrateConfigDocuments` 收的是各层的文档原文,不是文件文本)→
|
|
61
|
-
*
|
|
114
|
+
/** 一份**已解析的**存量文档在场时的值(同步口 `migrateConfigDocuments` 收的是各层的文档原文,不是文件文本)→ 它自己:一个嵌得不过深的 JSON 对象 ⇒ 它;`null` / 数组 / 数 / 串 / 布尔 /
|
|
115
|
+
* 非朴素对象、嵌得过深 ⇒ 抛。{@link judgeConfigDText} 的最后一道判就是这只(文本解析出来之后过它),所以「不是对象」「过深」各一种坏形一句话,不论文档是从文件文本来的还是调用方直接给的;
|
|
62
116
|
* `label` = 句首的称呼(文本路径给文件的真实路径,值路径给 `the <名> document`)。 */
|
|
63
117
|
export function judgeConfigDValue(name, value, label) {
|
|
118
|
+
const flaw = configDValueFlaw(name, value, label);
|
|
119
|
+
if (flaw !== undefined)
|
|
120
|
+
throw flaw.error;
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
/** {@link judgeConfigDValue} 的判据本体:值是一个嵌得不过深的朴素 JSON 对象 ⇒ `undefined`;否则 ⇒ 拒因词与要抛的那个错(交回,不抛 —— 读数与抛出口各取所需)。 */
|
|
124
|
+
/** 过深那一句(点名文件 / 文档与层数上限,句尾出路)。 */
|
|
125
|
+
function tooDeep(name, label) {
|
|
126
|
+
return { reason: "too_deep", error: new Error(`${label} nests deeper than ${MAX_CONFIG_D_DOCUMENT_DEPTH} levels (the config snapshot holding it could not be versioned) — ${documentWayOut(name)}`) };
|
|
127
|
+
}
|
|
128
|
+
function configDValueFlaw(name, value, label) {
|
|
64
129
|
const shape = jsonShapeOf(value);
|
|
130
|
+
// 「朴素」那一臂:是 JSON 对象 ⇒ 只剩深度一问(过深 = 这一份文档的文件层拒)
|
|
65
131
|
if (shape === "plain")
|
|
66
|
-
return value;
|
|
132
|
+
return nestsDeeperThan(value, MAX_CONFIG_D_DOCUMENT_DEPTH) ? tooDeep(name, label) : undefined;
|
|
67
133
|
// `shape` is narrowed to the five non-plain words: an exhaustive switch (a sixth word is a compile error here)
|
|
68
134
|
let kind;
|
|
69
135
|
switch (shape) {
|
|
@@ -77,7 +143,7 @@ export function judgeConfigDValue(name, value, label) {
|
|
|
77
143
|
kind = typeof value === "function" ? "function" : "non-plain object";
|
|
78
144
|
break;
|
|
79
145
|
case "uninspectable": // 读不出(撤销的 Proxy / 陷阱抛)不是「不是对象」:调用方给的对象读不出是调用方的错 ⇒ `TypeError`(v14.6.0:引擎对它抛的就是 TypeError),句子带全包唯一那一句
|
|
80
|
-
|
|
146
|
+
return { reason: "not_object", error: new TypeError(`${label} must be a JSON object (got ${UNINSPECTABLE}) — ${documentWayOut(name)}`) };
|
|
81
147
|
case "primitive":
|
|
82
148
|
kind = typeof value;
|
|
83
149
|
break;
|
|
@@ -86,10 +152,10 @@ export function judgeConfigDValue(name, value, label) {
|
|
|
86
152
|
throw new Error(`judgeConfigDValue: unhandled shape ${String(unhandled)}`);
|
|
87
153
|
}
|
|
88
154
|
}
|
|
89
|
-
|
|
155
|
+
return { reason: "not_object", error: new Error(`${label} must be a JSON object (got ${kind}) — ${documentWayOut(name)}`) };
|
|
90
156
|
}
|
|
91
|
-
/** 读一个 `config.d/<名>.json
|
|
92
|
-
export async function
|
|
157
|
+
/** 读一个 `config.d/<名>.json` → 读数:不存在(ENOENT)⇒ 缺席;在场 ⇒ {@link judgeConfigDTextReading};在场却读不出(非 ENOENT 的 fs 错误)⇒ 被拒,词 `io`。 */
|
|
158
|
+
export async function readConfigDFileReading(root, name) {
|
|
93
159
|
const file = configDPath(root, name);
|
|
94
160
|
let text;
|
|
95
161
|
try {
|
|
@@ -97,11 +163,30 @@ export async function readConfigDFile(root, name) {
|
|
|
97
163
|
}
|
|
98
164
|
catch (e) {
|
|
99
165
|
if (e.code === "ENOENT")
|
|
100
|
-
return
|
|
101
|
-
|
|
166
|
+
return { status: "absent" };
|
|
167
|
+
const error = messageOf(e);
|
|
168
|
+
return { status: "refused", reason: "io", error, thrown: e, fingerprint: stableHash(error) };
|
|
102
169
|
}
|
|
103
|
-
return
|
|
170
|
+
return judgeConfigDTextReading(name, text, file);
|
|
171
|
+
}
|
|
172
|
+
/** 抛出口:读一个 `config.d/<名>.json`:不存在(ENOENT)⇒ `undefined`;在场 ⇒ 它的值;被拒 ⇒ 抛读数的 `thrown`(见 {@link readConfigDFileReading})。 */
|
|
173
|
+
export async function readConfigDFile(root, name) {
|
|
174
|
+
const reading = await readConfigDFileReading(root, name);
|
|
175
|
+
if (reading.status === "refused")
|
|
176
|
+
throw reading.thrown;
|
|
177
|
+
return reading.status === "present" ? reading.value : undefined;
|
|
178
|
+
}
|
|
179
|
+
/** 一棵 `config.d` 原文快照的内容版本(稳定的 u53 整数):同样的文档 ⇒ 同一个数。`FileConfigStore` 的 `getVersion()` / `getEffective()` / `read()` 读同一只。 */
|
|
180
|
+
export function configDVersionOf(raws) {
|
|
181
|
+
return parseInt(stableHash(raws).slice(0, 13), 16); // 13 hex digits ≤ 2^52, safely within u53
|
|
182
|
+
}
|
|
183
|
+
/** 一份被拒文档在版本哈希里的哨兵:`{ __corruptDomainFile: <坏文档的指纹> }` —— 绝不与「文件缺席」同字节(`stableStringify` 把 `undefined` 与 `null` 折成同样的字节,
|
|
184
|
+
* 一份空 / 坏文件若读成缺席,会与「域缺席」同哈希,按版本轮询的消费方就漏掉一次变化),而且不同的坏内容换数:坏→修好、好→坏、坏内容 A→坏内容 B 都换版本。 */
|
|
185
|
+
export function corruptDocumentRaw(refused) {
|
|
186
|
+
return { __corruptDomainFile: refused.fingerprint };
|
|
104
187
|
}
|
|
188
|
+
/** retired 域名(`RETIRED_DOMAINS` 的键)—— 读它们的文件只为墓碑,次序 = 墓碑表的声明次序。 */
|
|
189
|
+
export const RETIRED_DOMAIN_NAMES = Object.keys(RETIRED_DOMAINS).filter(isRetiredDomainName);
|
|
105
190
|
/**
|
|
106
191
|
* `config.d/` 里每个 **retired 域**的文件过一次墓碑(`refuseRetiredDomain`,与 `buildEffective` / `parseDomain` 同一只判官、同一句):带事实 ⇒
|
|
107
192
|
* `RetiredDomainError`;缺席 / `{}` ⇒ 交回读到的原文(调用方要它进版本 hash)。读 `config.d/` 的**每个入口**都先过它 —— `FileConfigStore`
|
|
@@ -109,9 +194,7 @@ export async function readConfigDFile(root, name) {
|
|
|
109
194
|
*/
|
|
110
195
|
export async function refuseRetiredDomainFiles(root) {
|
|
111
196
|
const out = {};
|
|
112
|
-
for (const d of
|
|
113
|
-
if (!isRetiredDomainName(d))
|
|
114
|
-
continue;
|
|
197
|
+
for (const d of RETIRED_DOMAIN_NAMES) {
|
|
115
198
|
const raw = await readConfigDFile(root, d);
|
|
116
199
|
refuseRetiredDomain(d, raw);
|
|
117
200
|
out[d] = raw;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 契约的文件层拒一份 `config.d` 文档时交出的**类型化拒**(15.2.0)。纯数据与类型,零 node 依赖、零运行期 import —— 词表在包根,读目录的口(`FileConfigStore.read()`,16.0.0)在 `./node`。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么
|
|
5
|
+
* 文件层读一份文档只有抛一句话的出口:句子不带结构化的文档名,也分不出「空 / 坏 JSON / 不是对象 / 读不出」;并发读时一次只抛第一个到达的坏文档;
|
|
6
|
+
* 带事实的retired 域文档(墓碑)抛的是 `RetiredDomainError`,而retired 域文档自己读不出时谁也归不了属。消费方(服务端的本地读腿)于是在严读抛了之后,
|
|
7
|
+
* 再逐域重探、自己归属、自己铸「拒的身份」—— 契约的判据在别人那里有了第二份。这里把拒的**事实**交成数据:读法只有一种(文件读法的内核交结构化读数),
|
|
8
|
+
* 旧的抛出口是它的渲染(抛的还是原来那个对象、那句话),单读口 `FileConfigStore.read()` 是它在整个目录上的渲染(16.0.0 起;15.2.0 的 `inspectBundle` 只给拒单不给 effective,消费方因此要严读一次、
|
|
9
|
+
* 抛了再问它一次 —— 两读之间文档被修好,第二读的拒单是空的;它随单读口退役)。
|
|
10
|
+
*
|
|
11
|
+
* ## 形
|
|
12
|
+
* 一条拒 = `{ document, reason, error }`:
|
|
13
|
+
* · `document`:被拒的文档 —— 便携域名,或 retired 域名(retired 域的文档就以域名相称,墓碑拒的「域」就是这个名字,不另设第二个键);
|
|
14
|
+
* · `reason`:闭集词 {@link CONFIG_DOCUMENT_REFUSAL_REASONS}。文件层五个 —— `empty`(空 / 纯空白:半写、截断)· `invalid_json`(JSON 解析不过)· `not_object`(解析出来不是 JSON 对象:
|
|
15
|
+
* `null` / 数组 / 数 / 串 / 布尔)· `io`(在场却读不出:权限、路径是目录、I/O 错误;只有 ENOENT 是缺席,不是拒)· `too_deep`(16.0.0:嵌套深过整棵目录的内容版本能算的深度 ——
|
|
16
|
+
* 此前它在算版本那一步抛,已经收集的拒一并丢掉);第六个 `retired_domain` 是墓碑(retired 域文档带事实);
|
|
17
|
+
* · `error`:契约的整句,与旧出口抛出来的那一句逐字同(`empty` / `invalid_json` / `not_object` / `too_deep` 的句子点名文件的真实路径与形,从不回显文件原文;`io` 是 fs 错误的原话 ——
|
|
18
|
+
* 有的带真实路径(如 EACCES 的 `open '<路径>'`)、有的不带(EISDIR),要认是哪份文档读 `document`;调用方把 `error` 送出进程前自己脱敏);
|
|
19
|
+
* · 墓碑拒另带 `migration`(一次性迁移的编号,与 `RetiredDomainError.migration` 同值)。
|
|
20
|
+
* 词表是闭集:消费方用 `switch (refusal.reason)` 穷举,加词 ⇒ 它们编译红(这是通知)。
|
|
21
|
+
*/
|
|
22
|
+
import type { PortableDomain } from "./portable-domains.js";
|
|
23
|
+
import type { RetiredDomainName } from "./types.js";
|
|
24
|
+
/** 文件层拒一份文档的闭集词:五个文件层成因 + 墓碑(恒在末位)。墓碑那个词就是 `RetiredDomainError.code`(一个词,不立第二个名)。 */
|
|
25
|
+
export declare const CONFIG_DOCUMENT_REFUSAL_REASONS: readonly ["empty", "invalid_json", "not_object", "io", "too_deep", "retired_domain"];
|
|
26
|
+
export type ConfigDocumentRefusalReason = (typeof CONFIG_DOCUMENT_REFUSAL_REASONS)[number];
|
|
27
|
+
/** 能被拒的 `config.d` 文档:便携域的文档,或retired 域的文档(读它只为墓碑)。 */
|
|
28
|
+
export type ConfigDocumentName = PortableDomain | RetiredDomainName;
|
|
29
|
+
/** 被拒的一份文档(按 `reason` 判别):文件层的拒 `{ document, reason, error }`;墓碑拒另带 `migration`,文档名恒是 retired 域名。 */
|
|
30
|
+
export type ConfigDocumentRefusal = {
|
|
31
|
+
readonly document: ConfigDocumentName;
|
|
32
|
+
readonly reason: Exclude<ConfigDocumentRefusalReason, "retired_domain">;
|
|
33
|
+
readonly error: string;
|
|
34
|
+
} | {
|
|
35
|
+
readonly document: RetiredDomainName;
|
|
36
|
+
readonly reason: "retired_domain";
|
|
37
|
+
readonly error: string;
|
|
38
|
+
readonly migration: string;
|
|
39
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* device 车道的设备入口 `DEVICE_RELAY_URL` 的**值文法** —— 唯一判官(16.0.0)。纯函数,浏览器安全;只 import 零依赖的 URL 形判官模块。
|
|
3
|
+
*
|
|
4
|
+
* ## 规则(一条)
|
|
5
|
+
* 设备拨哪里是部署的必填声明,合法值只有两种:
|
|
6
|
+
* · 一个 ws(s) 绝对 URL —— 设备经中继 / 网关拨它。不带 userinfo / query / fragment:引擎把它**逐字**发在能力面上(设备拨号的地址),
|
|
7
|
+
* 没有放凭据的位置(设备凭签名鉴权);
|
|
8
|
+
* · 字面 {@link DEVICE_RELAY_NONE} —— 没有中继,设备按引擎自己的地址拨。只认这一种拼法(`None` / `NONE` 不是它)。
|
|
9
|
+
* 这里不判的两件,各有自己的判官:缺席 / 空白是车道表必需栏的事(写闸对每个必需坐标同一句,`isBlankCoordinate`);值里认得出凭据形的片段由写闸对
|
|
10
|
+
* 每个坐标值的那一只密钥判据拒。
|
|
11
|
+
*
|
|
12
|
+
* ## 谁读
|
|
13
|
+
* 写闸(`WorkerSpec` 的 `runtime.execution.coords.DEVICE_RELAY_URL`)、web-admin 表单与引擎起服问同一只;引擎把 `none` 读成「没有中继」。
|
|
14
|
+
*/
|
|
15
|
+
import { type WsUrlFlaw } from "./url-shape.js";
|
|
16
|
+
/** 「没有中继,设备拨引擎自己的地址」的那个字面值。 */
|
|
17
|
+
export declare const DEVICE_RELAY_NONE = "none";
|
|
18
|
+
/** 规则句(不含值):写闸的拒句、表单的提示读这一句。 */
|
|
19
|
+
export declare const DEVICE_RELAY_RULE = "must be an absolute ws:// or wss:// URL with no userinfo, query or fragment (ws(s)://host[:port][/path]) \u2014 the address devices dial, published verbatim \u2014 or the literal \"none\" (no relay: devices dial this engine's own address)";
|
|
20
|
+
/** 判一个**非空白**的值:合格(`none`,或合格的 ws(s) URL)⇒ `[]`;否则按判序交回拒因词(`not-ws-url` / `userinfo` / `query` / `fragment`)。 */
|
|
21
|
+
export declare function deviceRelayFlaws(value: string): readonly WsUrlFlaw[];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* device 车道的设备入口 `DEVICE_RELAY_URL` 的**值文法** —— 唯一判官(16.0.0)。纯函数,浏览器安全;只 import 零依赖的 URL 形判官模块。
|
|
3
|
+
*
|
|
4
|
+
* ## 规则(一条)
|
|
5
|
+
* 设备拨哪里是部署的必填声明,合法值只有两种:
|
|
6
|
+
* · 一个 ws(s) 绝对 URL —— 设备经中继 / 网关拨它。不带 userinfo / query / fragment:引擎把它**逐字**发在能力面上(设备拨号的地址),
|
|
7
|
+
* 没有放凭据的位置(设备凭签名鉴权);
|
|
8
|
+
* · 字面 {@link DEVICE_RELAY_NONE} —— 没有中继,设备按引擎自己的地址拨。只认这一种拼法(`None` / `NONE` 不是它)。
|
|
9
|
+
* 这里不判的两件,各有自己的判官:缺席 / 空白是车道表必需栏的事(写闸对每个必需坐标同一句,`isBlankCoordinate`);值里认得出凭据形的片段由写闸对
|
|
10
|
+
* 每个坐标值的那一只密钥判据拒。
|
|
11
|
+
*
|
|
12
|
+
* ## 谁读
|
|
13
|
+
* 写闸(`WorkerSpec` 的 `runtime.execution.coords.DEVICE_RELAY_URL`)、web-admin 表单与引擎起服问同一只;引擎把 `none` 读成「没有中继」。
|
|
14
|
+
*/
|
|
15
|
+
import { DIAL_ADDRESS_URL_FORBIDDEN_PARTS, wsUrlFlaws } from "./url-shape.js";
|
|
16
|
+
/** 「没有中继,设备拨引擎自己的地址」的那个字面值。 */
|
|
17
|
+
export const DEVICE_RELAY_NONE = "none";
|
|
18
|
+
/** 规则句(不含值):写闸的拒句、表单的提示读这一句。 */
|
|
19
|
+
export const DEVICE_RELAY_RULE = `must be an absolute ws:// or wss:// URL with no userinfo, query or fragment (ws(s)://host[:port][/path]) — the address devices dial, published verbatim — or the literal "${DEVICE_RELAY_NONE}" (no relay: devices dial this engine's own address)`;
|
|
20
|
+
/** 判一个**非空白**的值:合格(`none`,或合格的 ws(s) URL)⇒ `[]`;否则按判序交回拒因词(`not-ws-url` / `userinfo` / `query` / `fragment`)。 */
|
|
21
|
+
export function deviceRelayFlaws(value) {
|
|
22
|
+
return value === DEVICE_RELAY_NONE ? [] : wsUrlFlaws(value, DIAL_ADDRESS_URL_FORBIDDEN_PARTS);
|
|
23
|
+
}
|