@sema-agent/settings-schema 15.2.0 → 17.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/README.md +71 -46
  3. package/conformance/VERSION +1 -1
  4. package/conformance/cases/c16-deep-nesting-205/expected.json +2 -2
  5. package/conformance/cases/c16-deep-nesting-205/meta.json +1 -1
  6. package/conformance/cases/c50-s784-lane-host-legal/expected.json +6 -2
  7. package/conformance/cases/c50-s784-lane-host-legal/input.json +3 -1
  8. package/conformance/cases/c51-s784-lane-e2b-legal/expected.json +6 -2
  9. package/conformance/cases/c51-s784-lane-e2b-legal/input.json +3 -1
  10. package/conformance/cases/c52-s784-lane-k8s-legal/expected.json +6 -2
  11. package/conformance/cases/c52-s784-lane-k8s-legal/input.json +3 -1
  12. package/conformance/cases/c53-s784-lane-ssh-legal/expected.json +6 -2
  13. package/conformance/cases/c53-s784-lane-ssh-legal/input.json +3 -1
  14. package/conformance/cases/c54-s784-lane-adb-legal/expected.json +6 -2
  15. package/conformance/cases/c54-s784-lane-adb-legal/input.json +3 -1
  16. package/conformance/cases/c55-s784-lane-local-docker-legal/expected.json +6 -2
  17. package/conformance/cases/c55-s784-lane-local-docker-legal/input.json +3 -1
  18. package/conformance/cases/c56-s784-lane-device-legal/expected.json +8 -2
  19. package/conformance/cases/c56-s784-lane-device-legal/input.json +5 -2
  20. package/conformance/cases/c57-s784-g1-enabled-no-lane/meta.json +1 -1
  21. package/conformance/cases/c58-s784-g2-multitenant-host/input.json +3 -1
  22. package/conformance/cases/c59-s784-g3-k8s-missing-image/input.json +3 -1
  23. package/conformance/cases/c60-s784-g3-e2b-missing-key/input.json +3 -1
  24. package/conformance/cases/c61-s784-g3-other-lane-key/input.json +3 -1
  25. package/conformance/cases/c62-s784-g7a-session-tidb/input.json +3 -1
  26. package/conformance/cases/c63-s784-host-two-replicas/input.json +3 -1
  27. package/conformance/cases/c64-s784-multitenant-local-store/input.json +3 -1
  28. package/conformance/cases/c65-s784-pg-without-db/input.json +3 -1
  29. package/conformance/cases/c66-s784-memory-backend-mismatch/input.json +3 -1
  30. package/conformance/cases/c66-s784-memory-backend-mismatch/meta.json +1 -1
  31. package/conformance/cases/c68-s784-device-local-store/input.json +6 -1
  32. package/conformance/cases/cli04-env-value-with-newline/expected.json +6 -2
  33. package/conformance/cases/cli04-env-value-with-newline/input.json +3 -1
  34. package/dist/atomic-write.d.ts +4 -0
  35. package/dist/atomic-write.js +20 -0
  36. package/dist/bundle.d.ts +0 -18
  37. package/dist/bundle.js +1 -54
  38. package/dist/cli.d.ts +1 -1
  39. package/dist/config-d-file.d.ts +13 -12
  40. package/dist/config-d-file.js +72 -27
  41. package/dist/config-document-refusal.d.ts +9 -7
  42. package/dist/config-document-refusal.js +2 -2
  43. package/dist/device-relay.d.ts +21 -0
  44. package/dist/device-relay.js +23 -0
  45. package/dist/env-model-table.d.ts +2 -9
  46. package/dist/env-model-table.js +6 -14
  47. package/dist/execution-lane-index.d.ts +19 -7
  48. package/dist/execution-lane-index.js +26 -8
  49. package/dist/execution-lanes.d.ts +9 -4
  50. package/dist/execution-lanes.js +18 -6
  51. package/dist/file-store.d.ts +31 -4
  52. package/dist/file-store.js +78 -11
  53. package/dist/fleet.d.ts +6 -8
  54. package/dist/fleet.js +5 -7
  55. package/dist/hooks.d.ts +1 -1
  56. package/dist/index.d.ts +1 -0
  57. package/dist/index.js +2 -1
  58. package/dist/local-load.d.ts +12 -4
  59. package/dist/local-load.js +20 -31
  60. package/dist/migration-receipt.d.ts +1 -1
  61. package/dist/migration-receipt.js +1 -1
  62. package/dist/one-time-migrations.d.ts +1 -1
  63. package/dist/one-time-migrations.js +166 -45
  64. package/dist/remote-exec.js +11 -4
  65. package/dist/types.d.ts +15 -11
  66. package/dist/types.js +99 -48
  67. package/dist/url-shape.d.ts +18 -0
  68. package/dist/url-shape.js +22 -0
  69. package/docs/API-SURFACE.md +12 -8
  70. package/docs/CONFORMANCE.md +1 -7
  71. package/docs/CONSUMER-INTEGRATION.md +3 -3
  72. package/llms-full.txt +25 -29
  73. package/llms.txt +9 -8
  74. package/package.json +1 -1
@@ -17,6 +17,8 @@
17
17
  * · `tenantSeparation`:同文件 `executionLaneCaps` 每臂的 `tenantSeparation` 位(:209 host / :230 e2b / :249 k8s /
18
18
  * :269 ssh / :288 adb / :308 local-docker / :327 device)。`tenantSeparationBasis`(11.0.0):分租户的那几臂凭什么分 ——
19
19
  * e2b / k8s / local-docker 每任务一只隔离目标(`isolation`),device 是发起者自己的设备(`principal-binding`)。
20
+ * · `singleReplica`(16.0.0):这条车道的执行目标挂在**一个副本**上 —— host 是副本自己的文件系统,device 是设备连上的那一个副本
21
+ * (设备的连接落在一个副本上,跨副本把会话送到它的设备今天没有)。表里有一条这种车道的 worker 只能一个副本(写闸按这一列判,不点名车道词)。
20
22
  * · `requiredEnv ∪ requiredSecretEnv`:这条车道缺了就起不了服的键。最初逐字抄自 `src/config.ts` 的手写表 `REQUIRED_ENVS_BY_LANE`(:1054),
21
23
  * 那张手写表已退役 —— 服务端的必需 env 门按本表行的这两栏拒启,部署面的车道清单读同一行,所以今天**本表就是**必需键的唯一来源。按 `src/config-catalog.ts`
22
24
  * 该键那一行的值类型分栏 —— 类型 `secret` / `opaque`(目录自己「绝不回显值」的两类)进 `*SecretEnv`,其余进 `*Env`。
@@ -56,6 +58,7 @@ const LANE_ROWS = {
56
58
  /** server `config.ts` :4014 臂(无必需键);`execution-lane-caps.ts` :209 `single-user-only`(所有任务落 worker 宿主)。 */
57
59
  host: {
58
60
  tenantSeparation: "single-user-only",
61
+ singleReplica: true,
59
62
  requiredEnv: [],
60
63
  requiredSecretEnv: [],
61
64
  optionalEnv: ["HOST_WORKSPACE_BASE", "HOST_COMMAND_TIMEOUT_MS"],
@@ -66,6 +69,7 @@ const LANE_ROWS = {
66
69
  e2b: {
67
70
  tenantSeparation: "supported",
68
71
  tenantSeparationBasis: "isolation",
72
+ singleReplica: false,
69
73
  requiredEnv: [],
70
74
  requiredSecretEnv: ["E2B_API_KEY"],
71
75
  optionalEnv: ["E2B_TEMPLATE", "E2B_TIMEOUT_MS", "E2B_LIVENESS_MS", "E2B_ALLOW_NET", "E2B_MOUNT_PATH", "E2B_ORPHAN_RECLAIM"],
@@ -78,6 +82,7 @@ const LANE_ROWS = {
78
82
  k8s: {
79
83
  tenantSeparation: "supported",
80
84
  tenantSeparationBasis: "isolation",
85
+ singleReplica: false,
81
86
  requiredEnv: ["K8S_SANDBOX_IMAGE", "K8S_NAMESPACE"],
82
87
  requiredSecretEnv: [],
83
88
  optionalEnv: [
@@ -98,6 +103,7 @@ const LANE_ROWS = {
98
103
  * `execution-lane-caps.ts` :269 `single-user-only`(一台跨任务共享的常驻真机)。 */
99
104
  ssh: {
100
105
  tenantSeparation: "single-user-only",
106
+ singleReplica: false,
101
107
  requiredEnv: ["SSH_HOST", "SSH_USER"],
102
108
  requiredSecretEnv: ["SSH_PRIVATE_KEY"],
103
109
  optionalEnv: ["SSH_PORT", "SSH_MOUNT_PATH", "SSH_HOST_FINGERPRINT", "SSH_KNOWN_HOSTS"],
@@ -106,6 +112,7 @@ const LANE_ROWS = {
106
112
  /** server `config.ts` :4006 臂;`execution-lane-caps.ts` :288 `single-user-only`(一台跨任务共享的设备)。 */
107
113
  adb: {
108
114
  tenantSeparation: "single-user-only",
115
+ singleReplica: false,
109
116
  requiredEnv: ["ADB_SERIAL"],
110
117
  requiredSecretEnv: [],
111
118
  optionalEnv: ["ADB_PATH", "ADB_MOUNT_PATH"],
@@ -116,6 +123,7 @@ const LANE_ROWS = {
116
123
  "local-docker": {
117
124
  tenantSeparation: "supported",
118
125
  tenantSeparationBasis: "isolation",
126
+ singleReplica: false,
119
127
  requiredEnv: ["DOCKER_IMAGE"],
120
128
  requiredSecretEnv: [],
121
129
  optionalEnv: [
@@ -131,12 +139,14 @@ const LANE_ROWS = {
131
139
  ],
132
140
  optionalSecretEnv: ["DOCKER_SANDBOX_ENV"],
133
141
  },
134
- /** server `config.ts` :4055 臂 + `parseDevicePreAuthPerRemoteEnv` :871 / `parseDeviceEnrollTokenTtlEnv` :857(无必需键:设备是运行期
135
- * 连进来的);`execution-lane-caps.ts` :327 `supported`(发起者自己的设备)。 */
142
+ /** server `config.ts` :4055 臂 + `parseDevicePreAuthPerRemoteEnv` :871 / `parseDeviceEnrollTokenTtlEnv` :857;`execution-lane-caps.ts` :327
143
+ * `supported`(发起者自己的设备)。16.0.0:`DEVICE_RELAY_URL`(设备拨哪里)是**必需**坐标 —— 引擎的 device 臂缺它即拒启
144
+ * (`parseDeviceRelayUrlEnv`;此前它是 device 臂读而本表漏登记的键),值只有两种:ws(s) 绝对 URL 或字面 `none`(值的文法见 `./device-relay`)。 */
136
145
  device: {
137
146
  tenantSeparation: "supported",
138
147
  tenantSeparationBasis: "principal-binding",
139
- requiredEnv: [],
148
+ singleReplica: true,
149
+ requiredEnv: ["DEVICE_RELAY_URL"],
140
150
  requiredSecretEnv: [],
141
151
  optionalEnv: [
142
152
  "DEVICE_DELIVERY_TIMEOUT_MS",
@@ -157,6 +167,7 @@ export const EXECUTION_LANES = Object.freeze(EXECUTION_LANE_WORDS.map((lane) =>
157
167
  return Object.freeze({
158
168
  lane,
159
169
  ...tenancy,
170
+ singleReplica: row.singleReplica,
160
171
  requiredEnv: Object.freeze([...row.requiredEnv]),
161
172
  requiredSecretEnv: Object.freeze([...row.requiredSecretEnv]),
162
173
  optionalEnv: Object.freeze([...row.optionalEnv]),
@@ -166,7 +177,7 @@ export const EXECUTION_LANES = Object.freeze(EXECUTION_LANE_WORDS.map((lane) =>
166
177
  /**
167
178
  * 车道坐标的**唯一判官**:一个必需坐标「未设 / 空串 / 只含空白」一律算缺。本函数判其中的后两种,且只看**已经有的字符串值**:空串,或只含空白(`String.prototype.trim` 认的空白:空格 / 制表 / 换行 / 不换行空格 / 字节序标记 …)⇒ `true`;
168
179
  * 「这一键根本没设」由调用方先判(写闸对没设的键另有一句 `requires coords.<键>`,两种缺法同样被拒)。
169
- * 写闸对所有车道、所有必需坐标同判这一只;它只判「是不是空白」,不 trim 值、不管值的格式(格式不在这一条的范围:`" sandboxes "` 不是空白)。
180
+ * 写闸对所有车道、所有必需坐标同判这一只;本地文件契约 `RemoteExecSpec`(`config.d/remote-exec.json`)的每个非空串字段(镜像 / 命名空间 / 主机 / 路径 / …)问的也是这一只(16.0.0 起:同一个部署声明在两处说,「空白」不再有两种读法);它只判「是不是空白」,不 trim 值、不管值的格式(格式不在这一条的范围:`" sandboxes "` 不是空白)。
170
181
  * 它只答「这个值是不是空白」,不管这个键是必需还是可选:据它**拒不拒**一个坐标,由调用方按键的必需 / 可选决定 —— 写闸只对必需坐标据它拒;个别可选坐标上空串有语义(如 `K8S_RUNTIME_CLASS` 的空串 = 不用 Kata),可选坐标的空串照收。
171
182
  *
172
183
  * 住在这个零依赖的叶模块里:包根(`export *`)与 `./execution-lanes` 子路径是**同一个函数对象** —— 只想问这一个问题的消费方走子路径,不必冷载入整只契约包桶;
@@ -180,5 +191,6 @@ export const STORE_BACKENDS = ["mysql", "pg", "local", "memory"];
180
191
  /** session 店(→ `SESSION_BACKEND`)。= server `src/config.ts` :1760 `enumEnv("SESSION_BACKEND", …)` 的词表逐字同序
181
192
  * (`tidb` 不在集内:引擎见之拒启)。 */
182
193
  export const SESSION_BACKENDS = ["memory", "mysql", "auto"];
183
- /** 记忆持久面方言(→ `MEMORY_ENGINE_BACKEND`)。= server `src/config.ts` :3436 闭集 / 目录 :468 `enumValues` 逐字同序。 */
184
- export const MEMORY_ENGINE_BACKENDS = ["file", "pg", "tidb"];
194
+ /** 记忆持久面方言(→ `MEMORY_ENGINE_BACKEND`)。16.0.0:MySQL 族那一只叫 `mysql`(此前 `tidb` —— 同一只 SQL 店有店词 `mysql` 与方言 `tidb` 两个字;
195
+ * 收成一个字,与 {@link STORE_BACKENDS} 同字;硬破坏,`tidb` 读写两面拒、指路一次性迁移 SS-M12)。次序 file → pg → mysql。 */
196
+ export const MEMORY_ENGINE_BACKENDS = ["file", "pg", "mysql"];
@@ -1,10 +1,11 @@
1
1
  import { type EffectiveReadWarning } from "./config-fns.js";
2
+ import type { ConfigDocumentRefusal } from "./config-document-refusal.js";
2
3
  import type { ConfigReader } from "./reader.js";
3
4
  import { type DomainConfig, type DomainName, type EffectiveConfig, type RetiredDomainName } from "./types.js";
4
5
  export { NON_PORTABLE_DOMAIN_REASONS, PORTABLE_DOMAINS, type PortableDomain } from "./portable-domains.js";
5
6
  /** `config.d/` 里**合法存在但不是域文档**的文件名(不进 `"unread-config-file"` 告警)。`remote-exec`
6
7
  * 不是 `DOMAIN_SCHEMAS` 成员(resolver 不读它),按 service 表决住在这个目录,由 loadRemoteExec 读
7
- * ——**它有自己的读取与校验路径**,所以既不是「未知文件」也不是「域文件」。
8
+ * ——**它有自己的校验路径**(读盘与域文档同一只 `config.d` 文档读法,值判是 `RemoteExecSpec`),所以既不是「未知文件」也不是「域文件」。
8
9
  *
9
10
  * **导出**(3.0.0):一致性门的语料派生要按同一张表给本地腿的文件分类(域文件 / 专用文件 / 忽略),
10
11
  * 手抄一份必漂 —— 漂了的那一天,门会把一个合法的 `remote-exec.json` 报成未知文件。 */
@@ -21,6 +22,23 @@ export type TolerantEffective = {
21
22
  effective: EffectiveConfig;
22
23
  domainErrors: DomainReadError[];
23
24
  };
25
+ /** 单读口 {@link FileConfigStore.read} 的答案(按 `kind` 判别的闭集两臂;消费方 `switch` 穷举)。两臂都带 `warnings`(读侧告警交成数据,不经 `onWarning`)。 */
26
+ export type FileConfigRead =
27
+ /** 每份文档都读得进:`effective` 与严读 `getEffective()` 逐位同(同一份快照、同一个版本);`warnings` = 没人读的文件 + 装配时的读侧告警。 */
28
+ {
29
+ readonly kind: "effective";
30
+ readonly effective: EffectiveConfig;
31
+ readonly warnings: readonly EffectiveReadWarning[];
32
+ }
33
+ /** 至少一份文档在文件层被拒:`refusals` = 整棵目录的拒单(次序确定:retired 域文档在前,按墓碑表的声明次序;便携域按 `PORTABLE_DOMAINS` 的次序);
34
+ * `version` = 这一次读到的整棵目录的内容版本(被拒的文档以坏文档的指纹参与 —— 与宽容读的坏文件哨兵同一只:同样的坏内容同一个数、坏内容变了换数、
35
+ * 绝不与「文件缺席」同数);`warnings` = 没人读的文件(装配没有发生)。 */
36
+ | {
37
+ readonly kind: "refused";
38
+ readonly refusals: readonly ConfigDocumentRefusal[];
39
+ readonly version: number;
40
+ readonly warnings: readonly EffectiveReadWarning[];
41
+ };
24
42
  export declare class FileConfigStore implements ConfigReader {
25
43
  #private;
26
44
  private readonly root;
@@ -37,9 +55,10 @@ export declare class FileConfigStore implements ConfigReader {
37
55
  domainPath(domain: DomainName | RetiredDomainName): string;
38
56
  /** Read+parse one domain (validated, defaults filled), or `undefined` if the domain is ABSENT — matching
39
57
  * the {@link ConfigReader} contract (reader.ts: absent domain → undefined) and the server stores'
40
- * `has(domain) ? parse : undefined`. A present-but-empty file (`{}` / whitespace) IS a set domain → parsed
41
- * with defaults; a missing file is absence → undefined. (getEffective still fills defaults for absent
42
- * domains; getDomain is the per-domain "was this ever set?" probe.) */
58
+ * `has(domain) ? parse : undefined`. Only an ABSENT file (ENOENT with nothing at the path; a dangling symlink is not absence) is absence → undefined. A present `{}` IS a set
59
+ * domain → parsed with defaults. A present file that is empty or whitespace-only (a half-write) throws as a corrupt file,
60
+ * as do bad JSON, a non-object, an unreadable file and one nested too deep (the same reading as getEffective / readBundle).
61
+ * (getEffective still fills defaults for absent domains; getDomain is the per-domain "was this ever set?" probe.) */
43
62
  getDomain<K extends DomainName>(domain: K): Promise<DomainConfig[K] | undefined>;
44
63
  /** All portable domains → EffectiveConfig. The PAYLOAD and its `version` come from ONE raws snapshot (no
45
64
  * re-read between them — else a concurrent atomic rename could pair payload A with version B and a consumer
@@ -64,4 +83,12 @@ export declare class FileConfigStore implements ConfigReader {
64
83
  /** Stable version number for the current files: same files ⇒ same number ⇒ a consumer's "skip re-apply if
65
84
  * version unchanged" works locally too. */
66
85
  getVersion(): Promise<number>;
86
+ /**
87
+ * **单读口**(16.0.0):一次读整棵 `config.d`(全部便携域 + retired 域的墓碑文档,每份只读一次),交一个结局({@link FileConfigRead})——
88
+ * · 每份文档在文件层都读得进 ⇒ `effective`:与严读 `getEffective()` 逐位同;读侧告警交成数据(没人读的文件 + 装配告警,不经 `onWarning`);
89
+ * · 有文档在文件层被拒(空 / 坏 JSON / 不是对象 / 读不出 / 过深;带事实的 retired 域文档是墓碑拒,带迁移编号)⇒ `refused`:整棵目录的拒单与这一次读到的内容版本。
90
+ * 严读与 `readBundle` 读的是同一只文件读法(`./config-d-file` 的读数)—— 同一种坏形同一个词、同一句话;区别只在渲染:严读遇到第一份坏文档就抛,本方法把整个目录读完、逐份交出。
91
+ * 文件层之外的拒(读得出、过不了门控域的 schema)照严读抛 —— 那是判形,不在拒单里。拒单、版本、effective、告警都出自这一次读到的字节:没有「先抛、再重探」的第二次读。
92
+ */
93
+ read(): Promise<FileConfigRead>;
67
94
  }
@@ -11,9 +11,12 @@
11
11
  * <root>/.env secret VALUES (KEY=value) — the ONLY file holding secrets; gitignored, never read here.
12
12
  *
13
13
  * Rules:
14
- * - A MISSING file (ENOENT) → the domain is absent → schema defaults fill in (parseDomain tolerates undefined).
14
+ * - An ABSENT file (nothing at the path: ENOENT, and neither the file nor `config.d` is a dangling symlink) → the domain is absent → schema defaults
15
+ * fill in (parseDomain tolerates undefined). A DANGLING symlink is present-but-unreadable (a refusal, `io`), never absence.
15
16
  * A present-but-EMPTY/whitespace file is a half-write → THROW (fail-closed, like remote-exec); to mean
16
17
  * "absent/defaults" the file must be missing, not empty. A valid `{}` object IS a set domain (→ defaults).
18
+ * - `read()` (16.0.0) is the ONE read that answers both ways: the effective config + its read warnings as data when every
19
+ * document reads, or the typed refusal list of the whole directory + its version when one does not — every document read once.
17
20
  * - Non-portable domains are OMITTED on a single machine and resolve to their schema defaults (`{workers:[]}`
18
21
  * etc.) — no stubbing needed. 逐域为什么被排除见 {@link NON_PORTABLE_DOMAIN_REASONS}(禁裸排除)。
19
22
  * - A RETIRED domain's file (`config.d/runtime.json`, see `RETIRED_DOMAINS`) is read only for its tombstone: carrying facts ⇒
@@ -26,7 +29,7 @@
26
29
  */
27
30
  import { readdir, stat } from "node:fs/promises";
28
31
  import path from "node:path";
29
- import { buildEffective, DOMAIN_READ_FALLBACK, parseDomainLoud } from "./config-fns.js";
32
+ import { RetiredDomainError, buildEffective, DOMAIN_READ_FALLBACK, parseDomainLoud, refuseRetiredDomain } from "./config-fns.js";
30
33
  import { RETIRED_DOMAIN_NAMES, configDPath, configDVersionOf, corruptDocumentRaw, readConfigDFile, readConfigDFileReading, refuseRetiredDomainFiles } from "./config-d-file.js";
31
34
  import { PORTABLE_DOMAINS } from "./portable-domains.js";
32
35
  import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
@@ -35,7 +38,7 @@ import { isDomainName, isRetiredDomainName } from "./types.js";
35
38
  export { NON_PORTABLE_DOMAIN_REASONS, PORTABLE_DOMAINS } from "./portable-domains.js";
36
39
  /** `config.d/` 里**合法存在但不是域文档**的文件名(不进 `"unread-config-file"` 告警)。`remote-exec`
37
40
  * 不是 `DOMAIN_SCHEMAS` 成员(resolver 不读它),按 service 表决住在这个目录,由 loadRemoteExec 读
38
- * ——**它有自己的读取与校验路径**,所以既不是「未知文件」也不是「域文件」。
41
+ * ——**它有自己的校验路径**(读盘与域文档同一只 `config.d` 文档读法,值判是 `RemoteExecSpec`),所以既不是「未知文件」也不是「域文件」。
39
42
  *
40
43
  * **导出**(3.0.0):一致性门的语料派生要按同一张表给本地腿的文件分类(域文件 / 专用文件 / 忽略),
41
44
  * 手抄一份必漂 —— 漂了的那一天,门会把一个合法的 `remote-exec.json` 报成未知文件。 */
@@ -57,7 +60,7 @@ export class FileConfigStore {
57
60
  return configDPath(this.root, domain); // the one name guard (no traversal) lives there
58
61
  }
59
62
  /** Read + JSON.parse one domain file —— the ONE `config.d/` file reading (`./config-d-file`, shared with `readBundle`): a MISSING
60
- * file (ENOENT) → undefined = the domain is ABSENT (the ONLY way to mean "use defaults"); a present file that is EMPTY/whitespace-only,
63
+ * file (nothing at the path — ENOENT and not a dangling symlink) → undefined = the domain is ABSENT (the ONLY way to mean "use defaults"); a present file that is EMPTY/whitespace-only,
61
64
  * or parses to anything but a JSON OBJECT, is CORRUPT → throw, FAIL CLOSED (an empty present file is a half-write/truncation, NOT
62
65
  * "absent": treating it as absent would silently DROP a published domain — e.g. a truncated governance.json drops its
63
66
  * commandPolicy/autonomy guardrails). This also keeps getVersion in lock-step with getEffective/getDomain: stableStringify collapses
@@ -84,6 +87,11 @@ export class FileConfigStore {
84
87
  const onWarning = this.opts.onWarning;
85
88
  if (!onWarning)
86
89
  return;
90
+ for (const w of await this.#unreadFiles())
91
+ onWarning(w);
92
+ }
93
+ /** {@link #warnUnreadFiles} 的扫描本体:没人读的 `*.json` 逐个一条告警(单读口 `read()` 把它们交成数据,所以它总扫)。 */
94
+ async #unreadFiles() {
87
95
  let names;
88
96
  try {
89
97
  names = await readdir(path.join(this.root, "config.d"));
@@ -91,8 +99,9 @@ export class FileConfigStore {
91
99
  catch {
92
100
  // 目录读不了(不存在 / 只有 x 没有 r 的目录权限)⇒ 本次扫不了,**不抛**:这条是尽力而为的审计线,
93
101
  // 绝不能把一次本来能成功的 getEffective 拖成失败(域文件本身仍按名逐个打开,该失败的自然会失败)。
94
- return;
102
+ return [];
95
103
  }
104
+ const out = [];
96
105
  for (const name of names) {
97
106
  if (name.startsWith(".") || !name.endsWith(".json") || NON_DOMAIN_CONFIG_FILES.has(name))
98
107
  continue;
@@ -101,10 +110,11 @@ export class FileConfigStore {
101
110
  continue; // 真被读的域
102
111
  if (isRetiredDomainName(base))
103
112
  continue; // retired 域:由墓碑判(带事实 ⇒ getEffective 抛),不是「没人读的文件」
104
- onWarning(isDomainName(base)
113
+ out.push(isDomainName(base)
105
114
  ? { kind: "unread-config-file", file: name, domain: base, why: "non-portable-domain" }
106
115
  : { kind: "unread-config-file", file: name, why: "not-a-domain" });
107
116
  }
117
+ return out;
108
118
  }
109
119
  /** Raw (unparsed) values for every PORTABLE domain and every retired domain (the latter for the tombstone), keyed by
110
120
  * name (absent → undefined). Both feed the version hash: a retired file appearing / changing flips `getEffective`
@@ -133,21 +143,22 @@ export class FileConfigStore {
133
143
  }
134
144
  /** Read+parse one domain (validated, defaults filled), or `undefined` if the domain is ABSENT — matching
135
145
  * the {@link ConfigReader} contract (reader.ts: absent domain → undefined) and the server stores'
136
- * `has(domain) ? parse : undefined`. A present-but-empty file (`{}` / whitespace) IS a set domain → parsed
137
- * with defaults; a missing file is absence → undefined. (getEffective still fills defaults for absent
138
- * domains; getDomain is the per-domain "was this ever set?" probe.) */
146
+ * `has(domain) ? parse : undefined`. Only an ABSENT file (ENOENT with nothing at the path; a dangling symlink is not absence) is absence → undefined. A present `{}` IS a set
147
+ * domain → parsed with defaults. A present file that is empty or whitespace-only (a half-write) throws as a corrupt file,
148
+ * as do bad JSON, a non-object, an unreadable file and one nested too deep (the same reading as getEffective / readBundle).
149
+ * (getEffective still fills defaults for absent domains; getDomain is the per-domain "was this ever set?" probe.) */
139
150
  async getDomain(domain) {
140
151
  await refuseRetiredDomainFiles(this.root); // every read entry asks the tombstone first (exportBundle reads through here)
141
152
  const raw = await this.#readDomainRaw(domain);
142
153
  if (raw === undefined)
143
- return undefined; // missing/empty file → domain absent (not schema defaults)
154
+ return undefined; // absent file (nothing at the path) → domain absent (not schema defaults); a corrupt or unreadable file (empty, bad JSON, dangling symlink …) threw above
144
155
  // 0.19.0(对抗复审 复审 round2 F3):与 buildEffective **同一条** parse-and-warn 判据。此前本方法直接
145
156
  // parseDomain,于是 `exportBundle`(它就是逐域走 getDomain 读的)会把一份携带未知键的域**静默剥**
146
157
  // 成一份"干净"的 bundle —— 再 import 回去就是一次持久的配置丢失,全程零告警。
147
158
  return parseDomainLoud(domain, raw, this.opts.onWarning);
148
159
  }
149
160
  /** Content hash of a raws snapshot → a stable u53-ish version number (≤ Number.MAX_SAFE_INTEGER). The one reading is `configDVersionOf`
150
- * (`./config-d-file`) — `inspectBundle` asks the same function, so a clean tree has the same version from either. */
161
+ * (`./config-d-file`) — `read()` asks the same function, so a clean tree has the same version from either. */
151
162
  #versionOf(raws) {
152
163
  return configDVersionOf(raws);
153
164
  }
@@ -191,4 +202,60 @@ export class FileConfigStore {
191
202
  async getVersion() {
192
203
  return this.#versionOf(await this.#readAllRaw());
193
204
  }
205
+ /**
206
+ * **单读口**(16.0.0):一次读整棵 `config.d`(全部便携域 + retired 域的墓碑文档,每份只读一次),交一个结局({@link FileConfigRead})——
207
+ * · 每份文档在文件层都读得进 ⇒ `effective`:与严读 `getEffective()` 逐位同;读侧告警交成数据(没人读的文件 + 装配告警,不经 `onWarning`);
208
+ * · 有文档在文件层被拒(空 / 坏 JSON / 不是对象 / 读不出 / 过深;带事实的 retired 域文档是墓碑拒,带迁移编号)⇒ `refused`:整棵目录的拒单与这一次读到的内容版本。
209
+ * 严读与 `readBundle` 读的是同一只文件读法(`./config-d-file` 的读数)—— 同一种坏形同一个词、同一句话;区别只在渲染:严读遇到第一份坏文档就抛,本方法把整个目录读完、逐份交出。
210
+ * 文件层之外的拒(读得出、过不了门控域的 schema)照严读抛 —— 那是判形,不在拒单里。拒单、版本、effective、告警都出自这一次读到的字节:没有「先抛、再重探」的第二次读。
211
+ */
212
+ async read() {
213
+ const documents = [...RETIRED_DOMAIN_NAMES, ...PORTABLE_DOMAINS];
214
+ const [readings, unread] = await Promise.all([Promise.all(documents.map((d) => readConfigDFileReading(this.root, d))), this.#unreadFiles()]);
215
+ const raws = {};
216
+ const refusals = [];
217
+ documents.forEach((document, i) => {
218
+ const reading = readings[i];
219
+ switch (reading.status) {
220
+ case "absent":
221
+ raws[document] = undefined;
222
+ return;
223
+ case "refused":
224
+ raws[document] = corruptDocumentRaw(reading);
225
+ refusals.push({ document, reason: reading.reason, error: reading.error });
226
+ return;
227
+ case "present":
228
+ raws[document] = reading.value;
229
+ if (isRetiredDomainName(document)) {
230
+ const tombstone = tombstoneOf(document, reading.value);
231
+ if (tombstone !== undefined)
232
+ refusals.push(tombstone);
233
+ }
234
+ return;
235
+ default: {
236
+ const unhandled = reading;
237
+ throw new Error(`FileConfigStore.read: unhandled reading ${String(unhandled)}`);
238
+ }
239
+ }
240
+ });
241
+ const version = this.#versionOf(raws);
242
+ if (refusals.length > 0)
243
+ return { kind: "refused", refusals, version, warnings: unread };
244
+ const warnings = [...unread];
245
+ const updatedAt = await this.#maxMtimeIso();
246
+ const effective = buildEffective(version, (d) => raws[d], updatedAt, (w) => warnings.push(w));
247
+ return { kind: "effective", effective, warnings };
248
+ }
249
+ }
250
+ /** 墓碑判一次(与严读同一只判官 `refuseRetiredDomain`、同一句):retired 域文档带事实 ⇒ 一条 `retired_domain` 拒;缺席 / `{}` ⇒ `undefined`。 */
251
+ function tombstoneOf(domain, raw) {
252
+ try {
253
+ refuseRetiredDomain(domain, raw);
254
+ return undefined;
255
+ }
256
+ catch (e) {
257
+ if (e instanceof RetiredDomainError)
258
+ return { document: e.domain, reason: "retired_domain", error: e.message, migration: e.migration };
259
+ throw e;
260
+ }
194
261
  }
package/dist/fleet.d.ts CHANGED
@@ -166,12 +166,13 @@ export type RestartSignal = z.infer<typeof RestartSignal>;
166
166
  * `config.gateway_fallback_without_primary` — fallback gateways are declared without a primary;
167
167
  * `config.catalog_replaces_env_models` ({@link CATALOG_REPLACES_ENV_MODELS_CODE}) — on the LOCAL config leg, a candidate that
168
168
  * manages the model plane would replace a declared env model table (`MODEL_ID` …) while the config root's decision document
169
- * (`config.env-models.json`, read by `readEnvModelsDecision`) does not say `replaced` — the env models keep serving;
170
- * `config.summarize_model_missing` ({@link SUMMARIZE_MODEL_MISSING_CODE}) — the summarize role, which every task resolves,
171
- * would land on no servable entry (every task would be refused after adoption).
169
+ * (`config.env-models.json`, read by `readEnvModelsDecision`) does not say `replaced` — the env models keep serving.
172
170
  * Source (engine): the first two in `src/model-entry-refusal.ts`, the next two in `src/model-route-endpoint.ts:40,42`
173
171
  * (invariant `model-route-endpoint`, judged by the model-plane stage in `src/config-center/apply-effective.ts:664-665`); the
174
- * last two are named HERE first (the code is a contract word: this package names it and exports the two constants for the engine to read; the engine does not mint them today).
172
+ * last one is named HERE first (the code is a contract word: this package names it and exports the constant for the engine to read; the engine does not mint it today).
173
+ * 16.0.0 retired `config.summarize_model_missing` (and its constant) from this set: a role seat naming an entry the catalog does not serve
174
+ * reads as unset (it falls back down the role chain and the engine says so once) — no reading refuses a model plane for it, so it was never
175
+ * an engine group refusal. A reader still holding it gets it dropped by {@link readModelRefusals} like any code outside the set.
175
176
  */
176
177
  export declare const MODEL_ENTRY_REFUSAL_CODES: readonly ["config.model_no_brain", "config.model_entry_invalid"];
177
178
  export declare const ModelEntryRefusalCode: z.ZodEnum<{
@@ -181,12 +182,9 @@ export declare const ModelEntryRefusalCode: z.ZodEnum<{
181
182
  export type ModelEntryRefusalCode = z.infer<typeof ModelEntryRefusalCode>;
182
183
  /** The env-model-table confirmation gate's group-refusal code (see {@link MODEL_PLANE_REFUSAL_CODES}). */
183
184
  export declare const CATALOG_REPLACES_ENV_MODELS_CODE: "config.catalog_replaces_env_models";
184
- /** The summarize invariant's group-refusal code (see {@link MODEL_PLANE_REFUSAL_CODES}); a writer's plan answers `fix_first` with it. */
185
- export declare const SUMMARIZE_MODEL_MISSING_CODE: "config.summarize_model_missing";
186
- export declare const MODEL_PLANE_REFUSAL_CODES: readonly ["config.default_model_missing", "config.model_refused_name_minted", "config.model_route_endpoint_missing", "config.gateway_fallback_without_primary", "config.catalog_replaces_env_models", "config.summarize_model_missing"];
185
+ export declare const MODEL_PLANE_REFUSAL_CODES: readonly ["config.default_model_missing", "config.model_refused_name_minted", "config.model_route_endpoint_missing", "config.gateway_fallback_without_primary", "config.catalog_replaces_env_models"];
187
186
  export declare const ModelPlaneRefusalCode: z.ZodEnum<{
188
187
  "config.catalog_replaces_env_models": "config.catalog_replaces_env_models";
189
- "config.summarize_model_missing": "config.summarize_model_missing";
190
188
  "config.default_model_missing": "config.default_model_missing";
191
189
  "config.model_refused_name_minted": "config.model_refused_name_minted";
192
190
  "config.model_route_endpoint_missing": "config.model_route_endpoint_missing";
package/dist/fleet.js CHANGED
@@ -234,26 +234,24 @@ function readSignalBody(body, pick, schema) {
234
234
  * `config.gateway_fallback_without_primary` — fallback gateways are declared without a primary;
235
235
  * `config.catalog_replaces_env_models` ({@link CATALOG_REPLACES_ENV_MODELS_CODE}) — on the LOCAL config leg, a candidate that
236
236
  * manages the model plane would replace a declared env model table (`MODEL_ID` …) while the config root's decision document
237
- * (`config.env-models.json`, read by `readEnvModelsDecision`) does not say `replaced` — the env models keep serving;
238
- * `config.summarize_model_missing` ({@link SUMMARIZE_MODEL_MISSING_CODE}) — the summarize role, which every task resolves,
239
- * would land on no servable entry (every task would be refused after adoption).
237
+ * (`config.env-models.json`, read by `readEnvModelsDecision`) does not say `replaced` — the env models keep serving.
240
238
  * Source (engine): the first two in `src/model-entry-refusal.ts`, the next two in `src/model-route-endpoint.ts:40,42`
241
239
  * (invariant `model-route-endpoint`, judged by the model-plane stage in `src/config-center/apply-effective.ts:664-665`); the
242
- * last two are named HERE first (the code is a contract word: this package names it and exports the two constants for the engine to read; the engine does not mint them today).
240
+ * last one is named HERE first (the code is a contract word: this package names it and exports the constant for the engine to read; the engine does not mint it today).
241
+ * 16.0.0 retired `config.summarize_model_missing` (and its constant) from this set: a role seat naming an entry the catalog does not serve
242
+ * reads as unset (it falls back down the role chain and the engine says so once) — no reading refuses a model plane for it, so it was never
243
+ * an engine group refusal. A reader still holding it gets it dropped by {@link readModelRefusals} like any code outside the set.
243
244
  */
244
245
  export const MODEL_ENTRY_REFUSAL_CODES = ["config.model_no_brain", "config.model_entry_invalid"];
245
246
  export const ModelEntryRefusalCode = z.enum(MODEL_ENTRY_REFUSAL_CODES);
246
247
  /** The env-model-table confirmation gate's group-refusal code (see {@link MODEL_PLANE_REFUSAL_CODES}). */
247
248
  export const CATALOG_REPLACES_ENV_MODELS_CODE = "config.catalog_replaces_env_models";
248
- /** The summarize invariant's group-refusal code (see {@link MODEL_PLANE_REFUSAL_CODES}); a writer's plan answers `fix_first` with it. */
249
- export const SUMMARIZE_MODEL_MISSING_CODE = "config.summarize_model_missing";
250
249
  export const MODEL_PLANE_REFUSAL_CODES = [
251
250
  "config.default_model_missing",
252
251
  "config.model_refused_name_minted",
253
252
  "config.model_route_endpoint_missing",
254
253
  "config.gateway_fallback_without_primary",
255
254
  CATALOG_REPLACES_ENV_MODELS_CODE,
256
- SUMMARIZE_MODEL_MISSING_CODE,
257
255
  ];
258
256
  export const ModelPlaneRefusalCode = z.enum(MODEL_PLANE_REFUSAL_CODES);
259
257
  /**
package/dist/hooks.d.ts CHANGED
@@ -78,8 +78,8 @@ export declare const HookEventName: z.ZodEnum<{
78
78
  }>;
79
79
  export type HookEventName = z.infer<typeof HookEventName>;
80
80
  export declare const HookOwner: z.ZodEnum<{
81
- engine: "engine";
82
81
  shell: "shell";
82
+ engine: "engine";
83
83
  }>;
84
84
  export type HookOwner = z.infer<typeof HookOwner>;
85
85
  /**
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export * from "./types.js";
6
6
  export * from "./execution-lanes.js";
7
7
  export * from "./config-document-refusal.js";
8
8
  export * from "./url-shape.js";
9
+ export * from "./device-relay.js";
9
10
  export * from "./url-credential.js";
10
11
  export * from "./default-model.js";
11
12
  export { catalogManagesModelPlane, enabledEntryCount } from "./catalog-manages.js";
package/dist/index.js CHANGED
@@ -5,7 +5,8 @@
5
5
  export * from "./types.js";
6
6
  export * from "./execution-lanes.js"; // 7.0.0 M-R2: the ONE lane / store vocabulary (EXECUTION_LANE_WORDS / EXECUTION_LANES / STORE_BACKENDS / SESSION_BACKENDS / MEMORY_ENGINE_BACKENDS) + THE lane-coordinate blank judge isBlankCoordinate (empty or whitespace-only = absent; the write gate and every consumer ask this one function; whether a key is set, and whether an optional key may be empty, stays the caller's call; the same function object as on the `./execution-lanes` subpath) — a zero-import leaf, pure/browser-safe
7
7
  export * from "./config-document-refusal.js"; // 15.2.0: the typed refusal of one config.d document (CONFIG_DOCUMENT_REFUSAL_REASONS = empty / invalid_json / not_object / io / retired_domain; ConfigDocumentRefusal { document, reason, error } + the tombstone arm's migration) — the reading is `inspectBundle` in ./node; pure/browser-safe
8
- export * from "./url-shape.js"; // 11.0.0: THE URL shape judge (httpUrlFlaws / httpUrlRule / isHierarchicalUrl), pure/browser-safe
8
+ export * from "./url-shape.js"; // 11.0.0: THE URL shape judge (httpUrlFlaws / httpUrlRule / isHierarchicalUrl; 16.0.0 + wsUrlFlaws), pure/browser-safe
9
+ export * from "./device-relay.js"; // 16.0.0: THE value grammar of the device lane's DEVICE_RELAY_URL (DEVICE_RELAY_NONE / deviceRelayFlaws / DEVICE_RELAY_RULE), pure/browser-safe
9
10
  export * from "./url-credential.js"; // 14.5.0: THE "does this URL carry a credential" judge (findUrlCredential / findUrlUserinfo / URL_CREDENTIAL_REASONS / CREDENTIAL_PARAM_NAMES …), pure/browser-safe
10
11
  export * from "./default-model.js"; // 11.0.0: THE catalog-default resolver (roles.default → active tier ladder → fallback), pure/browser-safe
11
12
  export { catalogManagesModelPlane, enabledEntryCount } from "./catalog-manages.js"; // THE "does this models document manage the model plane" judge (raw half: at least one entry not explicitly disabled) + the log count it is derived from, pure/browser-safe
@@ -2,17 +2,25 @@ import { type RequiredEnvName } from "./file-edit.js";
2
2
  import { type EffectiveReadWarning } from "./config-fns.js";
3
3
  import { type RemoteExecSpec } from "./remote-exec.js";
4
4
  import type { EffectiveConfig } from "./types.js";
5
- /** The remoteExec file path under a config root (under config.d/, beside the per-domain files). */
5
+ /** The remoteExec file path under a config root (under config.d/, beside the per-domain files) — the ONE config.d path builder (`configDPath`) answers it. */
6
6
  export declare function remoteExecPath(root: string): string;
7
- /** Read `<root>/config.d/remote-exec.json` → a VALIDATED RemoteExecSpec, or undefined if the file is absent
8
- * (→ the engine defaults to `host` under CONFIG_PROVIDER=local). Throws (zod) on a present-but-invalid file. */
7
+ /** Read `<root>/config.d/remote-exec.json` → a VALIDATED RemoteExecSpec, or undefined if there is NOTHING at that path
8
+ * (→ the engine defaults to `host` under CONFIG_PROVIDER=local). Throws (zod) on a present-but-invalid file.
9
+ *
10
+ * ABSENT means no entry on the path — nothing else. A present-but-empty file is a corruption/half-write, NOT "use the default" — FAIL CLOSED (an empty file
11
+ * silently falling back to the host lane would be an isolation DOWNGRADE if the user meant local-docker); so is a DANGLING symbolic link, at the file or at
12
+ * `config.d` itself (the kernel follows the link and answers ENOENT, but the entry is there: present and unreadable — it throws the same one sentence the directory
13
+ * migration prints, with its way out: repair or remove the link). To get the host default, the file must be ABSENT, not empty and not a broken link.
14
+ * This function has no read of its own: it is THE `config.d` document reading (`readConfigDFile`, one absence rule, one text judge — empty / bad JSON / not an object, one sentence
15
+ * each — shared with FileConfigStore, readBundle and the directory migration); the sanitized value judge is the migration's too (`./config-d-file`). */
9
16
  export declare function loadRemoteExec(root: string): Promise<RemoteExecSpec | undefined>;
10
17
  /** Validate + ATOMICALLY write `<root>/config.d/remote-exec.json` (the package's one atomic writer, `./atomic-write`; never a half file). The
11
18
  * client's `config` editor writes the exec backend through this; the engine reads it via loadRemoteExec. */
12
19
  export declare function writeRemoteExec(root: string, spec: unknown): Promise<RemoteExecSpec>;
13
20
  /** Parse `<root>/.env` (KEY=VALUE, `#` comments, optional surrounding quotes) MERGED OVER process.env (the
14
21
  * file wins). The ONLY place secret VALUES live — the contract files carry env-NAMEs only. A `KEY=` with an
15
- * empty value IS present (the caller may treat empty as a weak warning). Absent .env → just process.env.
22
+ * empty value IS present (the caller may treat empty as a weak warning). Absent .env → just process.env — absent
23
+ * means no entry on the path (the one absence rule, `readTextIfPresent`): a DANGLING symbolic link at `.env` is present and unreadable, so it throws.
16
24
  * A `.env` value of `file:<path>` resolves to that file's trimmed content (file-NAME ref — key stays out of
17
25
  * env, contract stays env-NAME-only). Schema still rejects pasted plaintext (the contract field is a NAME). */
18
26
  export declare function loadEnvFile(root: string): Promise<Record<string, string>>;
@@ -10,34 +10,28 @@
10
10
  import { mkdir, readFile } from "node:fs/promises";
11
11
  import { homedir } from "node:os";
12
12
  import path from "node:path";
13
- import { writeFileAtomic } from "./atomic-write.js";
13
+ import { readTextIfPresent, writeFileAtomic } from "./atomic-write.js";
14
14
  import { FileConfigStore } from "./file-store.js";
15
15
  import { listRequiredEnvNames } from "./file-edit.js";
16
16
  import {} from "./config-fns.js";
17
- import { configDDocumentText, judgeConfigDText, parseRemoteExecDocument } from "./config-d-file.js";
17
+ import { configDDocumentText, configDPath, parseRemoteExecDocument, readConfigDFile } from "./config-d-file.js";
18
18
  import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
19
- /** The remoteExec file path under a config root (under config.d/, beside the per-domain files). */
19
+ /** The remoteExec file path under a config root (under config.d/, beside the per-domain files) — the ONE config.d path builder (`configDPath`) answers it. */
20
20
  export function remoteExecPath(root) {
21
- return path.join(root, "config.d", `${REMOTE_EXEC_DOCUMENT}.json`);
21
+ return configDPath(root, REMOTE_EXEC_DOCUMENT);
22
22
  }
23
- /** Read `<root>/config.d/remote-exec.json` → a VALIDATED RemoteExecSpec, or undefined if the file is absent
24
- * (→ the engine defaults to `host` under CONFIG_PROVIDER=local). Throws (zod) on a present-but-invalid file. */
23
+ /** Read `<root>/config.d/remote-exec.json` → a VALIDATED RemoteExecSpec, or undefined if there is NOTHING at that path
24
+ * (→ the engine defaults to `host` under CONFIG_PROVIDER=local). Throws (zod) on a present-but-invalid file.
25
+ *
26
+ * ABSENT means no entry on the path — nothing else. A present-but-empty file is a corruption/half-write, NOT "use the default" — FAIL CLOSED (an empty file
27
+ * silently falling back to the host lane would be an isolation DOWNGRADE if the user meant local-docker); so is a DANGLING symbolic link, at the file or at
28
+ * `config.d` itself (the kernel follows the link and answers ENOENT, but the entry is there: present and unreadable — it throws the same one sentence the directory
29
+ * migration prints, with its way out: repair or remove the link). To get the host default, the file must be ABSENT, not empty and not a broken link.
30
+ * This function has no read of its own: it is THE `config.d` document reading (`readConfigDFile`, one absence rule, one text judge — empty / bad JSON / not an object, one sentence
31
+ * each — shared with FileConfigStore, readBundle and the directory migration); the sanitized value judge is the migration's too (`./config-d-file`). */
25
32
  export async function loadRemoteExec(root) {
26
- const file = remoteExecPath(root);
27
- let text;
28
- try {
29
- text = await readFile(file, "utf8");
30
- }
31
- catch (e) {
32
- if (e.code === "ENOENT")
33
- return undefined;
34
- throw e;
35
- }
36
- // A present-but-empty file is a corruption/half-write, NOT "use the default" — FAIL CLOSED (an empty file
37
- // silently falling back to the host lane would be an isolation DOWNGRADE if the user meant local-docker).
38
- // To get the host default, the file must be ABSENT, not empty. The text judge is THE config.d one (empty / bad JSON / not an object, one sentence
39
- // each — the same judge FileConfigStore and the directory migration read with); the sanitized value judge is the migration's too (`./config-d-file`).
40
- return parseRemoteExecDocument(judgeConfigDText(REMOTE_EXEC_DOCUMENT, text, file));
33
+ const doc = await readConfigDFile(root, REMOTE_EXEC_DOCUMENT);
34
+ return doc === undefined ? undefined : parseRemoteExecDocument(doc);
41
35
  }
42
36
  /** Validate + ATOMICALLY write `<root>/config.d/remote-exec.json` (the package's one atomic writer, `./atomic-write`; never a half file). The
43
37
  * client's `config` editor writes the exec backend through this; the engine reads it via loadRemoteExec. */
@@ -77,7 +71,8 @@ async function resolveFileRef(spec, root) {
77
71
  }
78
72
  /** Parse `<root>/.env` (KEY=VALUE, `#` comments, optional surrounding quotes) MERGED OVER process.env (the
79
73
  * file wins). The ONLY place secret VALUES live — the contract files carry env-NAMEs only. A `KEY=` with an
80
- * empty value IS present (the caller may treat empty as a weak warning). Absent .env → just process.env.
74
+ * empty value IS present (the caller may treat empty as a weak warning). Absent .env → just process.env — absent
75
+ * means no entry on the path (the one absence rule, `readTextIfPresent`): a DANGLING symbolic link at `.env` is present and unreadable, so it throws.
81
76
  * A `.env` value of `file:<path>` resolves to that file's trimmed content (file-NAME ref — key stays out of
82
77
  * env, contract stays env-NAME-only). Schema still rejects pasted plaintext (the contract field is a NAME). */
83
78
  export async function loadEnvFile(root) {
@@ -85,15 +80,9 @@ export async function loadEnvFile(root) {
85
80
  for (const [k, v] of Object.entries(process.env))
86
81
  if (v !== undefined)
87
82
  merged[k] = v;
88
- let text;
89
- try {
90
- text = await readFile(path.join(root, ".env"), "utf8");
91
- }
92
- catch (e) {
93
- if (e.code === "ENOENT")
94
- return merged;
95
- throw e;
96
- }
83
+ const text = await readTextIfPresent(path.join(root, ".env"));
84
+ if (text === undefined)
85
+ return merged;
97
86
  for (const raw of text.split("\n")) {
98
87
  const line = raw.trim();
99
88
  if (!line || line.startsWith("#"))
@@ -7,7 +7,7 @@ import type { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
7
7
  import type { DomainName, RetiredDomainName } from "./types.js";
8
8
  /** 本版登记的一次性**数据**迁移 id(闭集;每个 id 在 `ONE_TIME_MIGRATIONS` 登记表里恰一行)。收据只许这些 id —— 同表登记的 wire 删码位
9
9
  * (`WIRE_RETIREMENT_IDS`,`./one-time-migrations`)不改任何存储形,铸不出收据,不在本集。 */
10
- export declare const ONE_TIME_MIGRATION_IDS: readonly ["SS-M1", "SS-M2", "SS-M3", "SS-M4", "SS-M5", "SS-M6", "SS-M7", "SS-M8", "SS-M9", "SS-M10", "SS-M11"];
10
+ export declare const ONE_TIME_MIGRATION_IDS: readonly ["SS-M1", "SS-M2", "SS-M3", "SS-M4", "SS-M5", "SS-M6", "SS-M7", "SS-M8", "SS-M9", "SS-M10", "SS-M11", "SS-M12"];
11
11
  export type OneTimeMigrationId = (typeof ONE_TIME_MIGRATION_IDS)[number];
12
12
  /**
13
13
  * 一条收据说迁移**对一个坐标做了什么**(闭集,每词一义):
@@ -1,6 +1,6 @@
1
1
  /** 本版登记的一次性**数据**迁移 id(闭集;每个 id 在 `ONE_TIME_MIGRATIONS` 登记表里恰一行)。收据只许这些 id —— 同表登记的 wire 删码位
2
2
  * (`WIRE_RETIREMENT_IDS`,`./one-time-migrations`)不改任何存储形,铸不出收据,不在本集。 */
3
- export const ONE_TIME_MIGRATION_IDS = ["SS-M1", "SS-M2", "SS-M3", "SS-M4", "SS-M5", "SS-M6", "SS-M7", "SS-M8", "SS-M9", "SS-M10", "SS-M11"];
3
+ export const ONE_TIME_MIGRATION_IDS = ["SS-M1", "SS-M2", "SS-M3", "SS-M4", "SS-M5", "SS-M6", "SS-M7", "SS-M8", "SS-M9", "SS-M10", "SS-M11", "SS-M12"];
4
4
  /**
5
5
  * 一条收据说迁移**对一个坐标做了什么**(闭集,每词一义):
6
6
  * - `moved` — 值搬到新位(`to`),义不变;
@@ -41,7 +41,7 @@ export type StoredDocuments = Readonly<Partial<Record<DomainName | RetiredDomain
41
41
  export interface StoredDocumentsMigration {
42
42
  /** 迁移后的文档集:没改的文档是**同一引用**;被移除的(retired 域)不在;新建的(例:迁出的 `governance` / `limits`)在。 */
43
43
  readonly docs: Readonly<Record<string, unknown>>;
44
- /** 逐条收据(步序 SS-M1 → SS-M2 → SS-M5 → SS-M6 → SS-M8 → SS-M9 → SS-M10 → SS-M7 → SS-M4:纯改写步在前,用新 schema 终判的 SS-M4 最后;步内按坐标)。
44
+ /** 逐条收据(步序 SS-M1 → SS-M2 → SS-M5 → SS-M6 → SS-M8 → SS-M9 → SS-M10 → SS-M7 → workers 行步〔SS-M12 改写 → SS-M4 改写 → 一次终判〕:纯改写步在前,用新 schema 终判的 workers 行步最后;步内按坐标)。
45
45
  * 只有 `refused` 的收据 = 什么都没改。 */
46
46
  readonly receipts: readonly MigrationReceipt[];
47
47
  }