@sema-agent/settings-schema 1.10.0 → 2.1.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 +215 -3
- package/README.md +29 -2
- package/dist/config-fns.d.ts +114 -4
- package/dist/config-fns.js +347 -14
- package/dist/cross-domain.d.ts +8 -4
- package/dist/cross-domain.js +6 -2
- package/dist/fleet.d.ts +1 -1
- package/dist/fleet.js +1 -1
- package/dist/hooks.d.ts +367 -19
- package/dist/hooks.js +76 -8
- package/dist/remote-exec.d.ts +4 -4
- package/dist/remote-exec.js +1 -1
- package/dist/resolve-roster.d.ts +1 -1
- package/dist/scheduler-store.d.ts +13 -3
- package/dist/scheduler-store.js +13 -3
- package/dist/skills-manifest.js +1 -1
- package/dist/types.d.ts +663 -642
- package/dist/types.js +95 -46
- package/package.json +2 -1
package/dist/config-fns.js
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure config functions + data shapes — LIFTED verbatim out of sema-registry's `store.ts` (the half that
|
|
3
|
+
* depends only on `./types`, zero app/DB/next coupling). sema-registry re-exports these so its existing
|
|
4
|
+
* `@/lib/config` importers keep working unchanged (ONE copy of the code, here).
|
|
5
|
+
*
|
|
6
|
+
* Two DETERMINISM refactors vs. the original store.ts (so the package is wall-clock-free and a local file
|
|
7
|
+
* source can produce byte-identical effective config for identical inputs):
|
|
8
|
+
* 1. `buildEffective(version, getRaw, updatedAt)` — `updatedAt` is now a REQUIRED param (was a
|
|
9
|
+
* `new Date().toISOString()` default). Every caller supplies it explicitly.
|
|
10
|
+
* 2. `emptyEffective()` seeds the epoch (`new Date(0).toISOString()`) instead of the wall clock, matching
|
|
11
|
+
* the stores' existing "epoch sentinel" reduce seed.
|
|
12
|
+
*
|
|
13
|
+
* The fat `ConfigStore` interface itself STAYS in sema-registry (app-only, ~32 publish/RBAC/worker-status
|
|
14
|
+
* methods); only the narrow {@link ConfigReader} (reader.ts) is carried here.
|
|
15
|
+
*/
|
|
16
|
+
// zod is a VALUE import here (not just a type): {@link salvageEntries} introspects a domain schema to find
|
|
17
|
+
// its entry collections. zod is this package's one runtime dependency, so this costs nothing new.
|
|
18
|
+
import { z } from "zod";
|
|
1
19
|
import { DOMAIN_SCHEMAS, GOVERNANCE_MIRROR_KEYS, HOSTS_ARRAY_MAX, HOST_NAME_MAX, InfraCostRates, LimitsConfig, SkillsManifestSchema, containsSecretToken } from "./types.js";
|
|
2
20
|
/** Redact secret-shaped tokens from a validation MESSAGE — a zod error echoes the offending value (e.g.
|
|
3
21
|
* `received 'sk-live-…'`), so a secret pasted into the WRONG field would leak via the error string. Redacts
|
|
@@ -45,7 +63,7 @@ export function isRole(v) {
|
|
|
45
63
|
/**
|
|
46
64
|
* Minimum role to WRITE a domain. Default = `editor`; `skills` and `mcp` are raised to `publisher`
|
|
47
65
|
* because their content goes straight into the agent's system prompt / tool surface — one edit retargets
|
|
48
|
-
* the prompt of every worker × scenario that loads it (
|
|
66
|
+
* the prompt of every worker × scenario that loads it (the mcp/skills design note §3.3, decision §6 Q3 从严).
|
|
49
67
|
* Looked up by the [domain] PUT route. Any domain absent here uses {@link DEFAULT_DOMAIN_WRITE_ROLE}.
|
|
50
68
|
*/
|
|
51
69
|
export const DEFAULT_DOMAIN_WRITE_ROLE = "editor";
|
|
@@ -105,6 +123,149 @@ export function parseDomain(domain, raw) {
|
|
|
105
123
|
return r.data;
|
|
106
124
|
throw sanitizeZodError(r.error); // recursive redaction (message/received/unionErrors) — no secret echo. R13/R14.
|
|
107
125
|
}
|
|
126
|
+
// ── 逐条容错律(1.11.0,S-144 按类修)───────────────────────────────────────────────────────────────
|
|
127
|
+
//
|
|
128
|
+
// 病:域解析整域 all-or-nothing —— 13 个模型里坏一条,整张目录退成 schema 默认 = 一条都不剩。缓解一直
|
|
129
|
+
// 是**逐键**打补丁(把新键改成 `z.unknown()` 让它别拒),那是给每个新键加特判,规则只会越来越多;
|
|
130
|
+
// 而本仓的调度存储早就有成熟的逐条容错形(坏记录逐条丢 + 逐条上报,其余全存活),只是没抬到域面。
|
|
131
|
+
//
|
|
132
|
+
// 本节把那条判据提成**域无关的单铸点**:`salvageEntries(domain, raw)` 从域 schema 自身派生出「哪些字段
|
|
133
|
+
// 是条目集合」(数组 / Record),逐条 parse,坏的丢掉并点名。域面唯一的调用点是 `buildEffective` 的
|
|
134
|
+
// `parseOr`,且**只在整域解析已经失败之后**才跑 —— 好配置的解析路径一字不动(与 0.8.1 的 grandfather
|
|
135
|
+
// 同姿势:先精确、后通用、成功路径 byte 不变)。
|
|
136
|
+
//
|
|
137
|
+
// 🔴 不新开档位:容错只发生在 {@link DOMAIN_READ_FALLBACK} 判 `"default"` 的域。gate 域(rosters /
|
|
138
|
+
// governance / entitlement / execution / workers / runtime / limits / projects)坏条目仍然 throw ——
|
|
139
|
+
// 悄悄丢掉一条 roster 就是那个 worker 静默拿到全量目录,与整域降级是同一条「放权,不许」的理由,所以
|
|
140
|
+
// 共用同一张表,不为容错另立一张。
|
|
141
|
+
/** 展开 zod 的包装层(`.optional()` / `.default()` / `.nullable()` / `.catch()` / `.transform()` /
|
|
142
|
+
* `.superRefine()`),拿到里面真正的那个 schema。包装不改变「条目能不能单独解析」这件事。 */
|
|
143
|
+
function unwrapSchema(schema) {
|
|
144
|
+
let cur = schema;
|
|
145
|
+
// 有界:包装层数是 schema 作者写出来的常数,8 层远超实际(防被畸形 schema 拖住)。
|
|
146
|
+
for (let i = 0; i < 8; i++) {
|
|
147
|
+
if (cur instanceof z.ZodEffects)
|
|
148
|
+
cur = cur.innerType();
|
|
149
|
+
else if (cur instanceof z.ZodOptional || cur instanceof z.ZodNullable)
|
|
150
|
+
cur = cur.unwrap();
|
|
151
|
+
else if (cur instanceof z.ZodDefault)
|
|
152
|
+
cur = cur.removeDefault();
|
|
153
|
+
else if (cur instanceof z.ZodCatch)
|
|
154
|
+
cur = cur.removeCatch();
|
|
155
|
+
else
|
|
156
|
+
return cur;
|
|
157
|
+
}
|
|
158
|
+
return cur;
|
|
159
|
+
}
|
|
160
|
+
/** 一次「什么都没丢」的抢救的位移表(冻结的共享空表 —— {@link rebaseWarningPaths} 对它恒等)。 */
|
|
161
|
+
const NO_ENTRIES_DROPPED = Object.freeze({});
|
|
162
|
+
/** 一条条目的 parse —— 坏就返回它的(脱敏)错误。恶意 getter/Proxy 在遍历中抛错时**绝不**外泄其文案
|
|
163
|
+
* (可能带 secret,与 parseDomain 的 R13 同纪律),只留一句中性话。 */
|
|
164
|
+
function judgeEntry(schema, value) {
|
|
165
|
+
try {
|
|
166
|
+
const r = schema.safeParse(value);
|
|
167
|
+
return r.success ? undefined : sanitizeZodError(r.error);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return new Error("entry could not be read during validation");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* 从**schema 自身**派生条目集合(数组字段 / Record 字段),逐条判,坏的丢掉并逐条记账。
|
|
175
|
+
*
|
|
176
|
+
* 入参是 **schema 而不是域名**:`skills` 在 wire 上不是域 schema 而是 manifest(`SkillsManifestSchema`),
|
|
177
|
+
* 在 `buildEffective` 之外单独判 —— 只吃域名的抢救点会把那条腿漏在门外,同一份病只因为换了张 schema
|
|
178
|
+
* 就没被覆盖。吃 schema ⇒ 域面与 wire 面共用同一条律,没有第二实现。
|
|
179
|
+
*
|
|
180
|
+
* 为什么从 schema 派生而不是写一张「域 → 条目字段」表:那张表是第二份真源,开新域必漏。schema 里
|
|
181
|
+
* 「这个字段是一组条目」这件事本来就写着,读它即可 —— 新域天然被覆盖,零登记动作。
|
|
182
|
+
*
|
|
183
|
+
* 🔴 **零幸存者不算「部分损坏」**:一个集合如果所有条目都坏,那它不是「部分损坏的集合」,而是**整个坏掉
|
|
184
|
+
* 的集合** —— 本函数对这种字段**一条都不丢**,原样留着让整域档位去裁。这条不是特判,是「抢救」的定义:
|
|
185
|
+
* 没有要抢救的东西。它同时把一整类 fail-open 陷阱一次性挡住 —— 本 schema 里有若干字段**空 = 不设限**
|
|
186
|
+
* (最典型的 `models.atModelAllowlist`:空/缺席 = 所有模型都可 @),把一份「全是坏值」的限制清单抢救成
|
|
187
|
+
* 空数组,等于把运维的限制悄悄删了。这里不去枚举「哪些字段空了等于放权」(枚举必漏),而是**从不凭空
|
|
188
|
+
* 造出一个空集合**。
|
|
189
|
+
*
|
|
190
|
+
* 🔴 **告警不许比它诊断的东西更危险**(复审 R1 两条,红先复现):① Record 的键是用户写的,完全可能就是
|
|
191
|
+
* 一条粘错位置的凭据,所以路径里的键段过 {@link redactSecrets} 才出门(不变量①:secret 值永不随错误外流,
|
|
192
|
+
* 与 parseDomain 的 R13/R14 同一条);② 整段遍历包 try/catch —— 读字段、枚举 Record 都会触发访问器,
|
|
193
|
+
* 抛出来的文案可能带 secret。抢救是**尽力而为**的:抢不动就当没抢救(返回原引用),让既有的整域档位去裁,
|
|
194
|
+
* 绝不把解析拖下水、更不回显它的错误(与同文件 `scanDropped` 逐字同纪律)。
|
|
195
|
+
*
|
|
196
|
+
* 边界(如实登记):① 只看**顶层**字段(条目集合就在顶层,嵌套的坏形属于条目自身的问题);② raw 不是
|
|
197
|
+
* 纯 JSON 对象、字段值与 schema 形状对不上(schema 说数组、文档给对象)⇒ 原样返回,交给整域档位裁;
|
|
198
|
+
* ③ 域级判据(重名 / 悬空 ref)不在本函数视野内 —— 丢条目救不了它们,调用方据此回落整域档位;
|
|
199
|
+
* ④ 只认 `ZodArray` / `ZodRecord` 两种条目集合形 —— 别的形(元组等)不抢救,退回整域档位(保守方向)。
|
|
200
|
+
*/
|
|
201
|
+
export function salvageEntries(schema, raw) {
|
|
202
|
+
try {
|
|
203
|
+
return salvageEntriesUnsafe(schema, raw);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return { raw, dropped: [], keptIndex: NO_ENTRIES_DROPPED }; // 反射/访问器抛错:抢不动就当没抢救,绝不外泄其文案
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function salvageEntriesUnsafe(schema, raw) {
|
|
210
|
+
if (!isPlainJsonObject(raw))
|
|
211
|
+
return { raw, dropped: [], keptIndex: NO_ENTRIES_DROPPED };
|
|
212
|
+
const root = unwrapSchema(schema);
|
|
213
|
+
if (!(root instanceof z.ZodObject))
|
|
214
|
+
return { raw, dropped: [], keptIndex: NO_ENTRIES_DROPPED };
|
|
215
|
+
const shape = root.shape;
|
|
216
|
+
const dropped = [];
|
|
217
|
+
const patch = {};
|
|
218
|
+
const keptIndex = {};
|
|
219
|
+
for (const [field, fieldSchema] of Object.entries(shape)) {
|
|
220
|
+
const value = raw[field];
|
|
221
|
+
if (value === undefined)
|
|
222
|
+
continue;
|
|
223
|
+
const collection = unwrapSchema(fieldSchema);
|
|
224
|
+
if (collection instanceof z.ZodArray && Array.isArray(value)) {
|
|
225
|
+
const element = collection.element;
|
|
226
|
+
const bad = [];
|
|
227
|
+
const keptFrom = [];
|
|
228
|
+
// `filter`(不是手写 `for (i < value.length)`):它在**进入时**就钉死了长度,而重读 `value.length`
|
|
229
|
+
// 会跟着一个在校验期间自我增殖的数组一路跑下去(病态 getter 在 zod 读字段时往同一个数组里再 push
|
|
230
|
+
// 一条)——抢救是尽力而为的旁路,绝不许把同步读挂死。下标在回调里收,`filter` 本来就给 index。
|
|
231
|
+
const kept = value.filter((entry, i) => {
|
|
232
|
+
const error = judgeEntry(element, entry);
|
|
233
|
+
if (error === undefined) {
|
|
234
|
+
keptFrom.push(i); // 压缩后第 keptFrom.length-1 条 = 原文档第 i 条
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
bad.push({ path: `${field}.${i}`, error });
|
|
238
|
+
return false;
|
|
239
|
+
});
|
|
240
|
+
// 零幸存者 ⇒ 没有要抢救的东西(见头注:绝不凭空造出一个空集合)。
|
|
241
|
+
if (bad.length > 0 && kept.length > 0) {
|
|
242
|
+
dropped.push(...bad);
|
|
243
|
+
patch[field] = kept;
|
|
244
|
+
keptIndex[field] = keptFrom; // 只在真压缩了的字段上登记(见 {@link EntryKeptIndex})
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
else if (collection instanceof z.ZodRecord && isPlainJsonObject(value)) {
|
|
248
|
+
const keySchema = collection.keySchema;
|
|
249
|
+
const valueSchema = collection.valueSchema;
|
|
250
|
+
const bad = [];
|
|
251
|
+
const kept = {};
|
|
252
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
253
|
+
const error = judgeEntry(keySchema, key) ?? judgeEntry(valueSchema, entry);
|
|
254
|
+
if (error === undefined)
|
|
255
|
+
kept[key] = entry;
|
|
256
|
+
else
|
|
257
|
+
bad.push({ path: `${field}.${redactSecrets(key)}`, error }); // 键是用户写的,可能就是一条粘错位置的凭据
|
|
258
|
+
}
|
|
259
|
+
if (bad.length > 0 && Object.keys(kept).length > 0) {
|
|
260
|
+
dropped.push(...bad);
|
|
261
|
+
patch[field] = kept;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (dropped.length === 0)
|
|
266
|
+
return { raw, dropped, keptIndex: NO_ENTRIES_DROPPED };
|
|
267
|
+
return { raw: { ...raw, ...patch }, dropped, keptIndex };
|
|
268
|
+
}
|
|
108
269
|
/**
|
|
109
270
|
* Shared GC predicate for worker-status rows (F10) — keeps memory/sqlite/tidb identical. Given the rows'
|
|
110
271
|
* `{name, updatedAt}` and the GC options, returns the names to prune (A: not in `keepNames`; B: older than
|
|
@@ -127,7 +288,13 @@ export function statusRowsToPrune(rows, opts) {
|
|
|
127
288
|
/** Lift the governance trio out of a LEGACY runtime doc (pre-0.6 stored runtime carried autonomy/commandPolicy/
|
|
128
289
|
* approvalRequire). Used as the governance domain's read fallback when governance was NEVER set — so a 0.5
|
|
129
290
|
* store upgrades with zero data migration and zero gate loss. An explicitly-set governance domain WINS
|
|
130
|
-
* (its absence-of-a-key then means "center not managing", never "fall back to legacy runtime").
|
|
291
|
+
* (its absence-of-a-key then means "center not managing", never "fall back to legacy runtime").
|
|
292
|
+
*
|
|
293
|
+
* SUNSET (1.11.0): removal is a **persistent-data** break — a store still carrying the 0.5 shape would lose
|
|
294
|
+
* its guardrails silently, so this arm cannot simply be deleted at a minor. It goes at the next MAJOR, with
|
|
295
|
+
* the standard persistent-data treatment (a loud refusal naming the domain + a one-time migration on the
|
|
296
|
+
* writer side), and the closing condition is observable rather than a promise: `buildEffective` now emits
|
|
297
|
+
* `legacy-governance-lifted` every time the arm actually fires, so a deployment can prove it no longer does. */
|
|
131
298
|
export function legacyGovernanceFromRuntime(rawRuntime) {
|
|
132
299
|
if (rawRuntime === null || typeof rawRuntime !== "object")
|
|
133
300
|
return undefined;
|
|
@@ -138,6 +305,107 @@ export function legacyGovernanceFromRuntime(rawRuntime) {
|
|
|
138
305
|
lifted[k] = r[k];
|
|
139
306
|
return Object.keys(lifted).length ? lifted : undefined;
|
|
140
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* 一条告警里的**点号路径**从「抢救压缩后的坐标」回写成「原文档的坐标」—— 纯函数,{@link EntryKeptIndex}
|
|
310
|
+
* 是它唯一的坐标账。
|
|
311
|
+
*
|
|
312
|
+
* 成案(S-323,消费方真案):整域 parse 失败后 {@link salvageEntries} 会把坏条目从数组里剔掉,之后的
|
|
313
|
+
* 诊断({@link droppedKeys} / {@link carriedUnknownKeys})是拿**压缩后**的文档做键集对比出来的,它报的
|
|
314
|
+
* `models.1` 指的是压缩后的第 1 条。运维照着这条告警去改原文档的第 1 条 —— 改的是别人的行,而且改完
|
|
315
|
+
* 告警还在(他以为改对了)。**告警必须说运维看得见的那份文档里的坐标**。
|
|
316
|
+
*
|
|
317
|
+
* 为什么回写在这里、而不是让扫描函数认识原文档:给 `scanDropped` / `scanCarried` 加一个「原始 raw」
|
|
318
|
+
* 第二入参,等于让扫描同时持有两套坐标,每条新告警都要自己决定用哪套。回写是**告警出门前**的一次
|
|
319
|
+
* 坐标换算,与诊断本身正交 —— 一个铸点,所有臂一次表态。
|
|
320
|
+
*
|
|
321
|
+
* **闭集穷举**:`switch` 覆盖 {@link EffectiveReadWarning} 的每个 `kind`,新 kind 不声明处置 = 编译红
|
|
322
|
+
* (#157 的闭集纪律)。只分两类,没有第三类:**带坐标的回写,不带坐标的原样返回**。
|
|
323
|
+
*
|
|
324
|
+
* 为什么 `entry-dropped` 也回写:它**不是**「恒为原坐标」,只是不经**铸它那一层自己**的表 —— 上面那条律
|
|
325
|
+
* 说的就是这件事。{@link salvageEntries} 铸 `entry-dropped` 时用的是它**入参**那份文档的坐标;如果那份
|
|
326
|
+
* 文档本身已被外层(hosts 的 {@link grandfatherHostsRead})压缩过,外层照样要回写它一次。把「不动」写成
|
|
327
|
+
* 一条臂的性质,就是把「哪一层」这件事错记在了「哪个 kind」上。
|
|
328
|
+
*
|
|
329
|
+
* **恒等纪律**:没有任何一条路径真的变了(含 `keptIndex` 为空)⇒ 返回**同一个对象引用**,连 `keys`
|
|
330
|
+
* 数组也不复制(与 {@link grandfatherHostsRead} 的「没夹紧就返回原引用」同一条 byte 不变律)。
|
|
331
|
+
*/
|
|
332
|
+
export function rebaseWarningPaths(w, keptIndex) {
|
|
333
|
+
switch (w.kind) {
|
|
334
|
+
case "unknown-keys-dropped":
|
|
335
|
+
case "unknown-keys-carried":
|
|
336
|
+
case "legacy-governance-lifted": {
|
|
337
|
+
const keys = rebasePaths(w.keys, keptIndex, "key");
|
|
338
|
+
return keys === w.keys ? w : { ...w, keys };
|
|
339
|
+
}
|
|
340
|
+
case "entry-dropped": {
|
|
341
|
+
const path = rebasePath(w.path, keptIndex, "entry");
|
|
342
|
+
return path === w.path ? w : { ...w, path };
|
|
343
|
+
}
|
|
344
|
+
// 以下三臂不带任何点号路径:`hosts-grandfathered` 点名的是主机**名**,另两条点名的是错误/文件名。
|
|
345
|
+
case "hosts-grandfathered":
|
|
346
|
+
case "domain-defaulted":
|
|
347
|
+
case "unread-config-file":
|
|
348
|
+
return w;
|
|
349
|
+
default: {
|
|
350
|
+
// 闭集穷举门:新 kind 必须在上面表态,否则这里编译红。运行期到不了(本联合由本模块铸造);万一
|
|
351
|
+
// 到了就**原样返回** —— 回写是诊断面的尽力而为,这里抛异常会被调用点的 catch 吞成「整域降级」,
|
|
352
|
+
// 那比不回写坏得多(一条不认识的告警 ⇒ 整张目录蒸发)。
|
|
353
|
+
const unhandled = w;
|
|
354
|
+
return unhandled;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** 一组点号路径的回写。全部原样 ⇒ 返回**原数组引用**(调用方据此判「什么都没变」)。 */
|
|
359
|
+
function rebasePaths(keys, keptIndex, grammar) {
|
|
360
|
+
let changed = false;
|
|
361
|
+
const out = keys.map((k) => {
|
|
362
|
+
const rebased = rebasePath(k, keptIndex, grammar);
|
|
363
|
+
if (rebased !== k)
|
|
364
|
+
changed = true;
|
|
365
|
+
return rebased;
|
|
366
|
+
});
|
|
367
|
+
return changed ? out : keys;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* `field.N.rest` → `field.<原 N>.rest`。**按段比对**,不在拼好的串上做前缀匹配:`models` 的位移表绝不许
|
|
371
|
+
* 动到 `modelsX.1.k`(同名前缀是两个字段)。
|
|
372
|
+
*
|
|
373
|
+
* 回写的**定义域 = `grammar` 那一支真正铸得出来的数组路径**,不是「长得像下标就算」(两支文法的差别与
|
|
374
|
+
* 合并的代价见 {@link PathGrammar})。三道门,缺一不回写:
|
|
375
|
+
* · `field` 必须在位移表里(= 那个字段真被压缩过);
|
|
376
|
+
* · 段数合文法:`"entry"` 支的数组条目**恰好两段**,`"key"` 支**至少三段**;
|
|
377
|
+
* · 下标段必须是**规范十进制**(`${i}` 的像:无前导零、无符号、无空白)且落在表长内。
|
|
378
|
+
* 不满足 ⇒ 原样返回。
|
|
379
|
+
*
|
|
380
|
+
* Record 形的条目路径(`${field}.${key}`)天然不受影响:Record 字段从不进位移表(条目按键寻址,丢一条
|
|
381
|
+
* 不动其余键的坐标),`hasOwnProperty` 那道门就把它挡在外面了。
|
|
382
|
+
*
|
|
383
|
+
* 如实登记的残余歧义(S-323c,本版**不消**):点号路径是有损编码 —— 键名里本来就允许含 `.`
|
|
384
|
+
* (见 {@link isReservedExtensionKey} 的分段匹配注),所以一个**字面叫 `models.1.k` 的顶层键**与
|
|
385
|
+
* `models[1]` 里的未知键 `k` 仍然编码成同一个串,无从分辨(裸 `models.1` 那一半已被上面的段数门挡住)。
|
|
386
|
+
* 那是编码本身的歧义(回写之前就在:两版都指不到那个字面键,只是数字不同);要消掉它得让扫描与抢救
|
|
387
|
+
* 改铸**结构化路径段**、出门时才 stringify —— 那是 {@link EffectiveReadWarning} 的 BREAKING,归下一个 major。
|
|
388
|
+
*/
|
|
389
|
+
function rebasePath(key, keptIndex, grammar) {
|
|
390
|
+
const firstDot = key.indexOf(".");
|
|
391
|
+
if (firstDot <= 0)
|
|
392
|
+
return key; // 顶层键,没有下标可言
|
|
393
|
+
const field = key.slice(0, firstDot);
|
|
394
|
+
if (!Object.prototype.hasOwnProperty.call(keptIndex, field))
|
|
395
|
+
return key;
|
|
396
|
+
const rest = key.slice(firstDot + 1);
|
|
397
|
+
const secondDot = rest.indexOf(".");
|
|
398
|
+
const bare = secondDot < 0;
|
|
399
|
+
if (bare !== (grammar === "entry"))
|
|
400
|
+
return key; // 段数不合本支文法 ⇒ 它不是这一支铸出来的数组路径
|
|
401
|
+
const indexText = bare ? rest : rest.slice(0, secondDot);
|
|
402
|
+
if (!/^(?:0|[1-9]\d*)$/.test(indexText))
|
|
403
|
+
return key; // 规范十进制才是 `${i}` 的像
|
|
404
|
+
const original = keptIndex[field][Number(indexText)];
|
|
405
|
+
if (original === undefined)
|
|
406
|
+
return key;
|
|
407
|
+
return bare ? `${field}.${original}` : `${field}.${original}.${rest.slice(secondDot + 1)}`;
|
|
408
|
+
}
|
|
141
409
|
/** 扫描深度上限(嵌套再深的配置文档不存在;有上限才不会被恶意深链拖住)。 */
|
|
142
410
|
const DROP_SCAN_MAX_DEPTH = 8;
|
|
143
411
|
/** 一条警告最多点名多少个键(告警载荷有界;超界时警告带 `truncatedFrom` = 真实总数,见 {@link droppedKeys})。 */
|
|
@@ -352,19 +620,25 @@ export const DOMAIN_READ_FALLBACK = {
|
|
|
352
620
|
/** READ-path grandfather clamp for the 0.8.0 hosts tightening (0.8.1, S3 M-1). Clamps ONLY the two bounds
|
|
353
621
|
* 0.8.0 added (name > 63 → drop the entry; array > 1024 → slice) so 0.7-era legal stored data keeps reading;
|
|
354
622
|
* everything else (regex, types, duplicates) is left for parseDomain — this is a targeted grandfather, not a
|
|
355
|
-
* lenient parser. PURE + idempotent; returns the ORIGINAL reference when nothing exceeds the bounds.
|
|
623
|
+
* lenient parser. PURE + idempotent; returns the ORIGINAL reference when nothing exceeds the bounds.
|
|
624
|
+
*
|
|
625
|
+
* SUNSET (1.11.0): same persistent-data class as the governance lift — it goes at the next MAJOR. This one was
|
|
626
|
+
* never silent (`hosts-grandfathered` fires whenever it clamps), so the closing condition is already
|
|
627
|
+
* observable: a deployment that has not seen that warning across a full read cycle no longer needs the arm. */
|
|
356
628
|
export function grandfatherHostsRead(raw) {
|
|
357
629
|
if (raw === null || typeof raw !== "object" || !Array.isArray(raw.hosts)) {
|
|
358
|
-
return { raw, droppedNames: [] }; // not the expected shape — let parseDomain rule on it
|
|
630
|
+
return { raw, droppedNames: [], keptIndex: NO_ENTRIES_DROPPED }; // not the expected shape — let parseDomain rule on it
|
|
359
631
|
}
|
|
360
632
|
const original = raw.hosts;
|
|
361
633
|
const droppedNames = [];
|
|
362
|
-
|
|
634
|
+
const keptFrom = [];
|
|
635
|
+
let hosts = original.filter((h, i) => {
|
|
363
636
|
const name = h !== null && typeof h === "object" ? h.name : undefined;
|
|
364
637
|
if (typeof name === "string" && name.length > HOST_NAME_MAX) {
|
|
365
638
|
droppedNames.push(name.slice(0, HOST_NAME_MAX) + "…"); // audit label — clipped so the warning itself stays bounded
|
|
366
639
|
return false;
|
|
367
640
|
}
|
|
641
|
+
keptFrom.push(i); // 夹紧后第 keptFrom.length-1 条 = 原文档第 i 条
|
|
368
642
|
return true;
|
|
369
643
|
});
|
|
370
644
|
let truncatedFrom;
|
|
@@ -373,32 +647,78 @@ export function grandfatherHostsRead(raw) {
|
|
|
373
647
|
hosts = hosts.slice(0, HOSTS_ARRAY_MAX);
|
|
374
648
|
}
|
|
375
649
|
if (droppedNames.length === 0 && truncatedFrom === undefined)
|
|
376
|
-
return { raw, droppedNames }; // identity — byte-stable
|
|
377
|
-
|
|
650
|
+
return { raw, droppedNames, keptIndex: NO_ENTRIES_DROPPED }; // identity — byte-stable
|
|
651
|
+
// 位移**只**来自按名剔条目;纯截断留下的是原数组前缀,幸存者下标不动 ⇒ 不登记(见 EntryKeptIndex)。
|
|
652
|
+
const keptIndex = droppedNames.length > 0 ? { hosts: keptFrom.slice(0, hosts.length) } : NO_ENTRIES_DROPPED;
|
|
653
|
+
return { raw: { ...raw, hosts }, droppedNames, truncatedFrom, keptIndex };
|
|
378
654
|
}
|
|
379
655
|
export function buildEffective(version, getRaw, updatedAt, onWarning) {
|
|
380
656
|
// 0.19.0 静默剥键/静默承运门:解析**成功**的域,把 schema 丢掉的键、以及 open-world 域里承运但本包
|
|
381
657
|
// 不认识的键(都含嵌套,点号路径)点名(行为不变,只是不再无声)。判据与 `getDomain` 同源
|
|
382
658
|
// ({@link parseDomainLoud})。只在成功路径上报——降级路径已经有 domain-defaulted 那条更重的警告。
|
|
383
659
|
const parseLoud = (domain, raw) => parseDomainLoud(domain, raw, onWarning);
|
|
660
|
+
/**
|
|
661
|
+
* 一层读的**告警出口**,外面裹了那一层的坐标换算(S-323b 统一律)。
|
|
662
|
+
*
|
|
663
|
+
* 读路径上有**两个压缩点**:{@link grandfatherHostsRead}(parse 之前按名剔 host 条目)与
|
|
664
|
+
* {@link salvageEntries}(整域 parse 失败后剔坏条目)。律只有一条:**一条告警用「铸它的那一层所见
|
|
665
|
+
* 文档」的坐标表达;哪一层压缩了文档,哪一层就把从它下方上来的告警回写一次。** 组合因此是自然的
|
|
666
|
+
* ——内层先换算、外层后换算,不重不漏,而每一层都只需要知道**自己**那本账。
|
|
667
|
+
*
|
|
668
|
+
* 位移表**空**(这一层这次什么都没压缩)⇒ 原样返回 `onWarning`,连一层闭包都不套 —— 与
|
|
669
|
+
* {@link rebaseWarningPaths} 的恒等纪律同一条律:没发生的事不留痕迹。
|
|
670
|
+
*/
|
|
671
|
+
const exitOf = (keptIndex) => onWarning === undefined || Object.keys(keptIndex).length === 0 ? onWarning : (w) => onWarning(rebaseWarningPaths(w, keptIndex));
|
|
384
672
|
// Generic READ guardrail (0.8.1): a domain that no longer parses degrades to its schema default + a warning
|
|
385
673
|
// — but ONLY where DOMAIN_READ_FALLBACK says the default is safe; gate-bearing domains keep throwing (决策
|
|
386
674
|
// 表与逐域理由见 DOMAIN_READ_FALLBACK). The success path is UNCHANGED (same parseDomain, same bytes).
|
|
387
|
-
|
|
675
|
+
//
|
|
676
|
+
// `emit` = 这一腿的告警出口(默认 = 本函数的 `onWarning`)。**刻意不收「外层压缩了多少」这种入参**:
|
|
677
|
+
// parseOr 不必知道自己上面还有没有一层,它只管把告警交给给它的那个出口 —— 换算是出口的事。
|
|
678
|
+
const parseOr = (domain, raw, emit = onWarning) => {
|
|
388
679
|
try {
|
|
389
|
-
return
|
|
680
|
+
return parseDomainLoud(domain, raw, emit);
|
|
390
681
|
}
|
|
391
682
|
catch (e) {
|
|
392
683
|
if (DOMAIN_READ_FALLBACK[domain] === "throw")
|
|
393
684
|
throw e;
|
|
394
|
-
|
|
685
|
+
// 逐条容错(1.11.0)在整域降级**之前**试一次:只丢掉真正坏掉的那几条条目,同域好兄弟活下来。
|
|
686
|
+
// 告警先缓存再决定 —— 一个域只给一个结论:救活了就逐条报 `entry-dropped`,没救活就只报
|
|
687
|
+
// `domain-defaulted`(那一域的条目反正一条都不剩,再讲「丢了第 3 条」只会误导)。
|
|
688
|
+
const salvage = salvageEntries(DOMAIN_SCHEMAS[domain], raw);
|
|
689
|
+
if (salvage.dropped.length > 0) {
|
|
690
|
+
try {
|
|
691
|
+
const buffered = [];
|
|
692
|
+
const parsed = parseDomainLoud(domain, salvage.raw, (w) => buffered.push(w));
|
|
693
|
+
// 统一律落地:`entry-dropped` 是**本层**(salvage)铸的,用的就是本层入参那份文档的坐标 ⇒
|
|
694
|
+
// 不过本层自己的表,直接交给出口(出口会补上更外层的换算);`buffered` 里的是对**本层压缩后**
|
|
695
|
+
// 文档的诊断,是从下方上来的 ⇒ 先过本层的表,再交给出口。scan 函数一字不动。
|
|
696
|
+
for (const d of salvage.dropped)
|
|
697
|
+
emit?.({ domain, kind: "entry-dropped", path: d.path, error: d.error });
|
|
698
|
+
for (const w of buffered)
|
|
699
|
+
emit?.(rebaseWarningPaths(w, salvage.keptIndex));
|
|
700
|
+
return parsed;
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
// 条目之外还有毛病(重名 / 悬空 ref / 域级判据)—— 丢条目救不了它,照常整域降级。
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
emit?.({ domain, kind: "domain-defaulted", error: e });
|
|
395
707
|
return parseDomain(domain, undefined); // schema default — always valid
|
|
396
708
|
}
|
|
397
709
|
};
|
|
398
710
|
// governance TRUTH: the domain itself; if it was never set, lift the trio from a legacy runtime doc
|
|
399
711
|
// (0.5→0.6 upgrade path — a2/local stores keep their gates without a data migration).
|
|
400
712
|
const governanceRaw = getRaw("governance");
|
|
401
|
-
|
|
713
|
+
let governanceSource = governanceRaw;
|
|
714
|
+
if (governanceRaw === undefined) {
|
|
715
|
+
const lifted = legacyGovernanceFromRuntime(getRaw("runtime"));
|
|
716
|
+
// 兼容臂真生效时出声(1.11.0):行为一字不变,但窗要能关就得有人看得见它还开着。
|
|
717
|
+
if (lifted !== undefined)
|
|
718
|
+
onWarning?.({ domain: "governance", kind: "legacy-governance-lifted", keys: Object.keys(lifted) });
|
|
719
|
+
governanceSource = lifted;
|
|
720
|
+
}
|
|
721
|
+
const governance = parseLoud("governance", governanceSource);
|
|
402
722
|
// runtime slot = limit residue ONLY. 0.7.1(BREAKING,service 回执收账):E1 的 governance→runtime
|
|
403
723
|
// 六闸双写镜像已撤——service 1.150.0 已切优先读 eff.governance 并回帖确认(撤双写以其确认为门槛,
|
|
404
724
|
// ask⑤ 顺序契约兑现)。READ 方向的 legacy lift(governance 域从未设时从旧 runtime 文档抬升三闸,
|
|
@@ -422,7 +742,9 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
|
|
|
422
742
|
systems: parseOr("systems", getRaw("systems")),
|
|
423
743
|
collab: parseOr("collab", getRaw("collab")),
|
|
424
744
|
workers: parseLoud("workers", getRaw("workers")),
|
|
425
|
-
|
|
745
|
+
// hosts 是读路径上**唯一**有外层压缩点的腿:`hostsGf.raw` 已被 grandfather 按名剔过条目,所以这一腿的
|
|
746
|
+
// 告警出口要裹上它的位移表(统一律:哪一层压缩,哪一层回写从它下方上来的告警)。
|
|
747
|
+
hosts: parseOr("hosts", hostsGf.raw, exitOf(hostsGf.keptIndex)),
|
|
426
748
|
runtime,
|
|
427
749
|
governance,
|
|
428
750
|
entitlement: parseLoud("entitlement", getRaw("entitlement")),
|
|
@@ -477,8 +799,19 @@ export function readEffectiveWire(raw, onWarning) {
|
|
|
477
799
|
skills = manifest.data;
|
|
478
800
|
}
|
|
479
801
|
else {
|
|
480
|
-
|
|
481
|
-
|
|
802
|
+
// 逐条容错律的 wire 腿:manifest 走的是另一张 schema,但**同一个**抢救点 —— 一条坏条目只丢它自己,
|
|
803
|
+
// 整张 skill 清单不再因此蒸发(零幸存者仍整份退默认,见 salvageEntries 头注)。
|
|
804
|
+
const salvage = salvageEntries(SkillsManifestSchema, r.skills);
|
|
805
|
+
const retry = salvage.dropped.length > 0 ? SkillsManifestSchema.safeParse(salvage.raw) : undefined;
|
|
806
|
+
if (retry?.success) {
|
|
807
|
+
for (const d of salvage.dropped)
|
|
808
|
+
onWarning?.({ domain: "skills", kind: "entry-dropped", path: d.path, error: d.error });
|
|
809
|
+
skills = retry.data;
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
onWarning?.({ domain: "skills", kind: "domain-defaulted", error: sanitizeZodError(manifest.error) });
|
|
813
|
+
skills = { skills: [] };
|
|
814
|
+
}
|
|
482
815
|
}
|
|
483
816
|
// Open-world passthrough: keys that are neither validated domains nor the envelope fields ride verbatim.
|
|
484
817
|
const passthrough = {};
|
package/dist/cross-domain.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-domain referential integrity — the PURE half, shared by the single-domain PUT and the batch write so
|
|
3
|
-
* BOTH enforce the same rules (
|
|
3
|
+
* BOTH enforce the same rules (the multi-roster design note §6). The FLEET-coupled `applyAutoPlacement` (which imports
|
|
4
4
|
* `../fleet/placement`) STAYS in sema-registry — it's not pure and not portable. This package carries only
|
|
5
|
-
* `refIntegrityIssues` + `
|
|
5
|
+
* `refIntegrityIssues` + `createSiblingResolver` + the ref-issue types.
|
|
6
6
|
*
|
|
7
7
|
* BATCH-AWARE: a sibling domain (the models a roster references, the rosters a worker references) may be
|
|
8
8
|
* changing in the SAME batch. So checks resolve siblings through a {@link SiblingResolver} that prefers a
|
|
@@ -21,8 +21,12 @@ export interface RefIssue {
|
|
|
21
21
|
export type SiblingResolver = <K extends keyof DomainConfig>(domain: K) => Promise<DomainConfig[K] | undefined>;
|
|
22
22
|
/** A resolver over the reader, optionally shadowed by pending (same-operation) writes keyed by domain.
|
|
23
23
|
* Widened from the fat ConfigStore to the narrow {@link ConfigReader} (it only calls `getDomain`) so a TOC
|
|
24
|
-
* file source can use it without the full store.
|
|
25
|
-
|
|
24
|
+
* file source can use it without the full store.
|
|
25
|
+
*
|
|
26
|
+
* 1.11.0 renamed this from `siblingResolver` (BREAKING, zero aliases): the naming law is `build*` returns
|
|
27
|
+
* plain DATA and `create*` returns a LIVE object — this returns a closure, so it was on the wrong side of
|
|
28
|
+
* the line and carried neither prefix. A consumer's compile error IS the notice; the fix is the new name. */
|
|
29
|
+
export declare function createSiblingResolver(store: ConfigReader, pending?: Partial<{
|
|
26
30
|
[K in keyof DomainConfig]: DomainConfig[K];
|
|
27
31
|
}>): SiblingResolver;
|
|
28
32
|
/** The domains whose writes carry cross-domain model/roster references we validate at save time. */
|
package/dist/cross-domain.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/** A resolver over the reader, optionally shadowed by pending (same-operation) writes keyed by domain.
|
|
2
2
|
* Widened from the fat ConfigStore to the narrow {@link ConfigReader} (it only calls `getDomain`) so a TOC
|
|
3
|
-
* file source can use it without the full store.
|
|
4
|
-
|
|
3
|
+
* file source can use it without the full store.
|
|
4
|
+
*
|
|
5
|
+
* 1.11.0 renamed this from `siblingResolver` (BREAKING, zero aliases): the naming law is `build*` returns
|
|
6
|
+
* plain DATA and `create*` returns a LIVE object — this returns a closure, so it was on the wrong side of
|
|
7
|
+
* the line and carried neither prefix. A consumer's compile error IS the notice; the fix is the new name. */
|
|
8
|
+
export function createSiblingResolver(store, pending) {
|
|
5
9
|
return async (domain) => {
|
|
6
10
|
if (pending && Object.prototype.hasOwnProperty.call(pending, domain))
|
|
7
11
|
return pending[domain];
|
package/dist/fleet.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @sema-agent/settings-schema/fleet — the PURE scheduling/fleet contract for the sema-center control plane
|
|
3
|
-
* (design: cc-parity
|
|
3
|
+
* (design: the cc-parity control-plane scheduling design note, 产品拍板 2026-07-04 — "one contract, three orchestrators").
|
|
4
4
|
*
|
|
5
5
|
* Seven domains (rolling update / discovery / traffic / failover / LB / quota / token governance) are ONE
|
|
6
6
|
* data-plane lifecycle contract reused by three deployment forms — whoever HOLDS the engine handle orchestrates,
|
package/dist/fleet.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @sema-agent/settings-schema/fleet — the PURE scheduling/fleet contract for the sema-center control plane
|
|
3
|
-
* (design: cc-parity
|
|
3
|
+
* (design: the cc-parity control-plane scheduling design note, 产品拍板 2026-07-04 — "one contract, three orchestrators").
|
|
4
4
|
*
|
|
5
5
|
* Seven domains (rolling update / discovery / traffic / failover / LB / quota / token governance) are ONE
|
|
6
6
|
* data-plane lifecycle contract reused by three deployment forms — whoever HOLDS the engine handle orchestrates,
|