@sema-agent/settings-schema 14.6.0 → 14.8.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 +475 -0
- package/README.md +89 -5
- package/conformance/VERSION +1 -1
- package/conformance/cases/c84-sandbox-git-block-carried-verbatim/expected.json +153 -0
- package/conformance/cases/c84-sandbox-git-block-carried-verbatim/input.json +23 -0
- package/conformance/cases/c84-sandbox-git-block-carried-verbatim/meta.json +5 -0
- package/conformance/cases/c85-sandbox-git-refused-block-still-carried/expected.json +153 -0
- package/conformance/cases/c85-sandbox-git-refused-block-still-carried/input.json +23 -0
- package/conformance/cases/c85-sandbox-git-refused-block-still-carried/meta.json +5 -0
- package/dist/api/auth-bridge.d.ts +16 -2
- package/dist/api/auth-bridge.js +59 -24
- package/dist/api/credentials.d.ts +281 -0
- package/dist/api/credentials.js +149 -0
- package/dist/api/scopes.js +5 -1
- package/dist/atomic-write.d.ts +15 -0
- package/dist/atomic-write.js +109 -5
- package/dist/catalog-manages.d.ts +15 -0
- package/dist/catalog-manages.js +60 -0
- package/dist/center-refs.d.ts +68 -0
- package/dist/center-refs.js +70 -0
- package/dist/config-d-file.js +33 -15
- package/dist/config-dir-migration.js +1 -0
- package/dist/config-fns.js +147 -81
- package/dist/config-root-documents.d.ts +106 -0
- package/dist/config-root-documents.js +93 -0
- package/dist/config-root-node.d.ts +117 -0
- package/dist/config-root-node.js +260 -0
- package/dist/conformance/canonical.js +4 -4
- package/dist/conformance/contract-adapter.js +26 -1
- package/dist/conformance/corpus.js +4 -4
- package/dist/conformance/derive.js +2 -2
- package/dist/conformance/run.d.ts +3 -0
- package/dist/conformance/run.js +53 -12
- package/dist/conformance/temp-roots.d.ts +31 -0
- package/dist/conformance/temp-roots.js +184 -0
- package/dist/cross-session.d.ts +4 -4
- package/dist/declared-capture.d.ts +41 -0
- package/dist/declared-capture.js +344 -0
- package/dist/default-model.d.ts +1 -1
- package/dist/default-model.js +5 -4
- package/dist/env-model-table.d.ts +163 -0
- package/dist/env-model-table.js +257 -0
- package/dist/file-edit.js +11 -15
- package/dist/fleet.d.ts +36 -7
- package/dist/fleet.js +83 -32
- package/dist/hash.d.ts +0 -9
- package/dist/hash.js +64 -7
- package/dist/hooks-config.js +18 -3
- package/dist/http-hook-allowlists.d.ts +22 -9
- package/dist/http-hook-allowlists.js +169 -91
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/mcp-request-entry.d.ts +67 -13
- package/dist/mcp-request-entry.js +132 -93
- package/dist/mcp-server-name.d.ts +17 -0
- package/dist/mcp-server-name.js +19 -0
- package/dist/migration-receipt.d.ts +10 -6
- package/dist/migration-receipt.js +5 -3
- package/dist/node.d.ts +1 -0
- package/dist/node.js +2 -0
- package/dist/one-time-migrations.d.ts +4 -2
- package/dist/one-time-migrations.js +236 -96
- package/dist/own-key-read.d.ts +247 -0
- package/dist/own-key-read.js +449 -0
- package/dist/plain-json-object.d.ts +11 -1
- package/dist/plain-json-object.js +37 -1
- package/dist/remote-exec.js +9 -1
- package/dist/retired-form.d.ts +19 -0
- package/dist/retired-form.js +24 -1
- package/dist/safety-merge-spec.d.ts +6 -6
- package/dist/safety-merge-spec.js +50 -37
- package/dist/sandbox-git.d.ts +64 -0
- package/dist/sandbox-git.js +89 -0
- package/dist/scheduler-store.js +19 -11
- package/dist/settings-units.js +14 -12
- package/dist/tier-words.d.ts +33 -0
- package/dist/tier-words.js +43 -0
- package/dist/types.d.ts +12 -2
- package/dist/types.js +35 -6
- package/dist/url-shape.d.ts +16 -0
- package/dist/url-shape.js +18 -0
- package/dist/web-admin-credential.js +5 -2
- package/dist/web-admin-session-url.js +2 -2
- package/dist/worker-row-verdict.d.ts +11 -0
- package/dist/worker-row-verdict.js +58 -2
- package/dist/workers-rows.js +38 -10
- package/dist/zod-unwrap.d.ts +6 -0
- package/dist/zod-unwrap.js +14 -7
- package/docs/API-SURFACE.md +100 -5
- package/docs/CONSUMER-INTEGRATION.md +250 -2
- package/llms-full.txt +26 -4
- package/llms.txt +11 -4
- package/package.json +2 -2
- package/dist/definition-copy.d.ts +0 -23
- package/dist/definition-copy.js +0 -52
package/dist/conformance/run.js
CHANGED
|
@@ -11,15 +11,15 @@
|
|
|
11
11
|
* 比对分三面,刻意不混:**effective**(剥掉元数据后结构化深相等)、**告警多重集**、**元数据**
|
|
12
12
|
* (`version`/`updatedAt` 由 harness 注入或由腿自己算,独立断言 —— 不让 canonical 把它盖过去)。
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
15
|
-
import { tmpdir } from "node:os";
|
|
14
|
+
import { mkdir, readFile, realpath, stat, writeFile, utimes } from "node:fs/promises";
|
|
16
15
|
import { basename, extname, isAbsolute, join, resolve, sep } from "node:path";
|
|
17
|
-
import { isPlainJsonObject } from "../plain-json-object.js";
|
|
16
|
+
import { isJsonArray, isPlainJsonObject, jsonShapeOf } from "../plain-json-object.js";
|
|
18
17
|
import { canonicalize, deepEqual, diffJson, stableKey } from "./canonical.js";
|
|
19
18
|
import { diffWarningMultiset, findSecretLeaks, projectWarning } from "./warnings.js";
|
|
20
19
|
import { deriveLocalExpectation, deriveLocalInput, deriveWireExpectation, deriveWireInput, toManifestDoc } from "./derive.js";
|
|
21
20
|
import { kitIdentity, loadCorpus } from "./corpus.js";
|
|
22
21
|
import { contractOwnedEntry, contractOwnedEntryWhy } from "./entry-origin.js";
|
|
22
|
+
import { mintLocalRoot, removeLocalRoot } from "./temp-roots.js";
|
|
23
23
|
import { LEDGER_REF_RULE, LEG_DECLINE_REASONS, LEG_NAMES, } from "./types.js";
|
|
24
24
|
/** 一个**模块**文件的样子 —— 来源律用它挡住「随便找个存在的文件当入口」(`package.json` 也是文件)。 */
|
|
25
25
|
const MODULE_EXTENSIONS = [".ts", ".mts", ".cts", ".tsx", ".js", ".mjs", ".cjs", ".jsx"];
|
|
@@ -28,16 +28,20 @@ const MODULE_EXTENSIONS = [".ts", ".mts", ".cts", ".tsx", ".js", ".mjs", ".cjs",
|
|
|
28
28
|
* mtime 怎么设、目录怎么建就会各不相同,而 `updatedAt` 正是按 mtime 算的)。
|
|
29
29
|
*
|
|
30
30
|
* 返回 `root` 与 `cleanup`;所有文件的 mtime 统一设成 `mtime`,于是 `updatedAt` 对同一份快照恒定。
|
|
31
|
+
*
|
|
32
|
+
* 临时根只在 `./temp-roots` 里铸、只在那里删:进程在 `cleanup()` 之前死掉(`process.exit`、未捕获异常、信号、SIGKILL)时根留在 tmp 下,
|
|
33
|
+
* 根里的 `.alive` 超过 60 分钟没更新之后,由下一轮第一次铸根的清扫收走 —— 这里不碰临时目录的路径、前缀和清理,也不在进程上装任何监听。
|
|
31
34
|
*/
|
|
32
35
|
export async function materializeLocalRoot(files, mtime) {
|
|
33
36
|
// 闸**先于**任何 fs 动作:一个坏名字不许留下半个临时根(「建了再清」比这多一条「清到一半又抛」的路)。
|
|
34
37
|
for (const name of Object.keys(files))
|
|
35
38
|
assertBareFileName(name);
|
|
36
|
-
const root = await
|
|
39
|
+
const root = await mintLocalRoot();
|
|
37
40
|
const dir = join(root, "config.d");
|
|
38
|
-
await mkdir(dir, { recursive: true });
|
|
39
41
|
const when = new Date(mtime);
|
|
40
42
|
try {
|
|
43
|
+
// 不递归:根是刚铸出来的,只差这一层;根若已不在,这里响着失败(失败即清根),不顺手把根重建成一个不是 `mintLocalRoot` 铸出来的目录
|
|
44
|
+
await mkdir(dir);
|
|
41
45
|
for (const [name, text] of Object.entries(files)) {
|
|
42
46
|
const p = join(dir, name);
|
|
43
47
|
// 上面那条单段律要保证的**就是**这条包含律。它是承重面上的断言,不是可跳过的装饰:真要有一天
|
|
@@ -50,10 +54,10 @@ export async function materializeLocalRoot(files, mtime) {
|
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
56
|
catch (e) {
|
|
53
|
-
await
|
|
57
|
+
await removeLocalRoot(root);
|
|
54
58
|
throw e;
|
|
55
59
|
}
|
|
56
|
-
return { root, cleanup: () =>
|
|
60
|
+
return { root, cleanup: () => removeLocalRoot(root) };
|
|
57
61
|
}
|
|
58
62
|
/**
|
|
59
63
|
* 语料落盘的**文件名闸**。语料是仓外贡献面(每条 case 的 `input.json` 里的 `localFiles` 键,以及
|
|
@@ -178,7 +182,7 @@ function ledgerIndexForOutcome(ledger, caseId, leg, shape) {
|
|
|
178
182
|
if (o === undefined)
|
|
179
183
|
continue;
|
|
180
184
|
// 形不合法的登记(`cases` 缺席/空 —— 违例已单记)绝不因此**扩大**覆盖:它谁都不命中,fail-closed。
|
|
181
|
-
if (o.leg !== leg || !
|
|
185
|
+
if (o.leg !== leg || !isJsonArray(o.cases) || !o.cases.includes(caseId))
|
|
182
186
|
continue;
|
|
183
187
|
if (o.expected !== shape.expected || o.actual !== shape.actual)
|
|
184
188
|
continue;
|
|
@@ -289,12 +293,49 @@ function isCalendarDate(v) {
|
|
|
289
293
|
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;
|
|
290
294
|
}
|
|
291
295
|
function stanceOf(adapter, leg) {
|
|
296
|
+
// an adapter is CODE (a stance may be a class instance with `run` on its prototype), not data: its members are read as an object's members — what the seat decides is only "is it an object at all"
|
|
292
297
|
const legs = adapter.legs;
|
|
293
|
-
const
|
|
298
|
+
const legsShape = jsonShapeOf(legs);
|
|
299
|
+
let legsIsObject;
|
|
300
|
+
switch (legsShape) {
|
|
301
|
+
case "null":
|
|
302
|
+
case "primitive":
|
|
303
|
+
case "uninspectable":
|
|
304
|
+
legsIsObject = false;
|
|
305
|
+
break;
|
|
306
|
+
case "plain":
|
|
307
|
+
case "array":
|
|
308
|
+
legsIsObject = true;
|
|
309
|
+
break;
|
|
310
|
+
case "non_plain_object":
|
|
311
|
+
legsIsObject = typeof legs !== "function"; // 类实例是对象;函数不是(v14.6.0 的 `typeof === "object"` 判法:函数 ⇒ 没表态)
|
|
312
|
+
break;
|
|
313
|
+
default: {
|
|
314
|
+
const unhandled = legsShape;
|
|
315
|
+
throw new Error(`stanceOf: unhandled shape ${String(unhandled)}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const s = legsIsObject ? legs[leg] : undefined;
|
|
294
319
|
if (s === undefined)
|
|
295
320
|
return { kind: "missing" };
|
|
296
|
-
|
|
297
|
-
|
|
321
|
+
const stanceShape = jsonShapeOf(s);
|
|
322
|
+
switch (stanceShape) {
|
|
323
|
+
case "null":
|
|
324
|
+
case "primitive":
|
|
325
|
+
case "uninspectable":
|
|
326
|
+
return { kind: "malformed", why: "the stance is not an object" };
|
|
327
|
+
case "plain":
|
|
328
|
+
case "array":
|
|
329
|
+
break;
|
|
330
|
+
case "non_plain_object":
|
|
331
|
+
if (typeof s === "function")
|
|
332
|
+
return { kind: "malformed", why: "the stance is not an object" }; // 函数不是对象表态(类实例是)
|
|
333
|
+
break;
|
|
334
|
+
default: {
|
|
335
|
+
const unhandled = stanceShape;
|
|
336
|
+
throw new Error(`stanceOf: unhandled shape ${String(unhandled)}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
298
339
|
const runs = typeof s.run === "function";
|
|
299
340
|
const declines = Object.prototype.hasOwnProperty.call(s, "declined");
|
|
300
341
|
// 与类型同律(两臂互斥):声明臂上出现实现臂的任一字段(`entry` / `run`)都算「既实现又声明」。
|
|
@@ -376,7 +417,7 @@ async function releaseViolations(adapter, opts, corpus, kit) {
|
|
|
376
417
|
continue;
|
|
377
418
|
}
|
|
378
419
|
const cases = e.outcome.cases;
|
|
379
|
-
if (!
|
|
420
|
+
if (!isJsonArray(cases) || cases.length === 0)
|
|
380
421
|
v.push(`known divergence \`${name}\` is an outcome divergence that must name at least one case — 结局形不许按前缀大包一整条腿的拒绝面`);
|
|
381
422
|
}
|
|
382
423
|
// **每条腿都要表态**:实现,或显式声明不承担(闭集理由词 + why)。缺表态 / 形不对 ⇒ 违例 —— 不许静默跳过,也不许默认当通过。
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** 临时根的目录名前缀;名字 = 前缀 + `mkdtemp` 的六位随机(清扫只认这一种形)。 */
|
|
2
|
+
export declare const LOCAL_ROOT_PREFIX = "settings-conformance-";
|
|
3
|
+
/** 根内的心跳文件名:静态为空的文件,活进程定时(同一个 inode 才写)写一个字节再截回空、mtime 由文件系统自己打;清扫只看它的 mtime 年龄。 */
|
|
4
|
+
export declare const LOCAL_ROOT_ALIVE_FILE = ".alive";
|
|
5
|
+
/**
|
|
6
|
+
* 旧件的年龄线:60 分钟 —— 根里 `.alive` 的 mtime 早于它,清扫才删这个根。一个 case 的一条本地腿只要几毫秒、整套语料也只要几秒,60 分钟远长于一次正常的跑,
|
|
7
|
+
* 所以心跳停了这么久的根绝大多数是上一轮被收走的残件;线又短到清扫能及时收走它们,不让它们一天天攒。它必须远大于 {@link ALIVE_TOUCH_INTERVAL_MS},事件循环照常转的活进程的 `.alive` 才不会老到这条线。
|
|
8
|
+
* 具名常量,不是旋钮。
|
|
9
|
+
*/
|
|
10
|
+
export declare const STALE_LOCAL_ROOT_AGE_MS: number;
|
|
11
|
+
/** 心跳间隔:5 分钟 —— 活进程每隔这么久在根里的 `.alive` 上写一个字节再截回空(文件系统给它打新的 mtime)。它必须远小于 {@link STALE_LOCAL_ROOT_AGE_MS}(现为十二分之一),偶尔一拍慢了也不会让活进程的 `.alive` 老过年龄线。具名常量,不是旋钮。 */
|
|
12
|
+
export declare const ALIVE_TOUCH_INTERVAL_MS: number;
|
|
13
|
+
/**
|
|
14
|
+
* 清扫 `dir` 下的旧临时根:名字恰是「前缀 + 六位字母数字」、`lstat` 是目录(符号链接与文件一概不动)、属主是当前用户(有 `getuid` 的平台)、
|
|
15
|
+
* 根内有 `.alive` 且 `lstat` 是常规文件(目录、符号链接都不算)、`nowMs - <.alive 的 mtime>` 超过 `maxAgeMs` ⇒ 整根删。没有 `.alive` 的一概不动。
|
|
16
|
+
* 不问进程号、不问主机名 —— 活性证据只有 `.alive` 的 mtime 年龄。返回被删的目录名。读不了 `dir` / 删不掉某一个 ⇒ 跳过,不抛。
|
|
17
|
+
*/
|
|
18
|
+
export declare function sweepStaleLocalRoots(opts: {
|
|
19
|
+
readonly dir: string;
|
|
20
|
+
readonly nowMs: number;
|
|
21
|
+
readonly maxAgeMs?: number;
|
|
22
|
+
}): Promise<string[]>;
|
|
23
|
+
/**
|
|
24
|
+
* 铸一个临时根:根内建空文件 `.alive`、记下它的身份就关(本模块按路径建它只此一处)、起它的心跳定时器;本进程第一次铸根时先清扫旧件(见文件头 ①)。
|
|
25
|
+
* 铸出这个根之前任何一步失败(建 `.alive`、起定时器),都回滚到什么也没留下 —— 停定时器、删根,再把原错抛出去。返回根的绝对路径。
|
|
26
|
+
*/
|
|
27
|
+
export declare function mintLocalRoot(): Promise<string>;
|
|
28
|
+
/**
|
|
29
|
+
* 删一个临时根:先停它的心跳定时器,再只删这一个(删不掉就抛,留下的根等 `.alive` 超过 60 分钟没更新之后的旧件清扫收)。不开不关任何文件;还在飞的一拍不等它。
|
|
30
|
+
*/
|
|
31
|
+
export declare function removeLocalRoot(root: string): Promise<void>;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 符合性 kit 的临时根:唯一铸点 + 两道收口 —— 包内私有(不进 `./conformance` 的导出面)。
|
|
3
|
+
*
|
|
4
|
+
* `materializeLocalRoot` 每个 case 建一个 `<tmp>/settings-conformance-<随机>/`(`mkdtemp` 直铸的形);正常路径上由适配器 `finally` 里的 `cleanup()` 删。
|
|
5
|
+
* 进程在两者之间死掉(`process.exit`、未捕获异常、信号、SIGKILL)时 `finally` 不跑,根就留在宿主 tmp 下 —— 语料一大就是成千上万个残件。两道收口:
|
|
6
|
+
* ① **旧件清扫**:活性证据是**根里的心跳文件**。铸根时在根内以 `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW` 建一个空文件 {@link LOCAL_ROOT_ALIVE_FILE}(`.alive`,静态为空),`fstat` 记下它的 `(dev, ino)` 就**关掉**;再起一只 `unref()` 的定时器,
|
|
7
|
+
* 每 {@link ALIVE_TOUCH_INTERVAL_MS}(5 分钟)一拍:按路径 `O_WRONLY | O_NOFOLLOW | O_NONBLOCK` 打开 `.alive`(不带 `O_CREAT`;`O_NONBLOCK` 让被换成没有读端的命名管道的 `.alive` 当场打不开〔`ENXIO`〕作罢,不在文件系统线程池里一直阻塞)、`fstat` 比 `(dev, ino)`,**相等才**写一个字节(换行,空白)再截回空,mtime 由文件系统自己打(两步都刷新它),写完关掉;不相等就关掉、这一拍不写;
|
|
8
|
+
* **心跳按 inode 身份写、不持句柄**:规则只有一条「同一 inode 才写」—— `.alive` 被换成符号链接(有 `O_NOFOLLOW` 的平台上打开就被拒、这一拍作罢;没有它的平台上打开会跟过去,身份比对仍拦住指向别的文件的链接)、被硬链接到别的文件、被换成另一个文件或目录、被删,或根被删后同名重建,心跳都不写别的文件,也不把任何路径建出来(打开不带 `O_CREAT`);
|
|
9
|
+
* 身份是文件系统的 `(dev, ino)`:要让它错认,得先在这个根(`mkdtemp` 铸出的、只有属主进得去的目录)里删掉 `.alive`、再让另一个文件拿到同一个 inode 号并放在这个名字下 —— 能做到这一步的,本来就能直接写这个根里的任何文件;
|
|
10
|
+
* **模块里没有常驻的文件描述符**:铸根时那一只用完即关,每一拍那一只也是一拍一开一关,所以一次铸许多个根、`cleanup()` 留到最后统一调的调用方,铸多少个根都不因此多占描述符;
|
|
11
|
+
* 进程以任何方式死掉,心跳随它停,`.alive` 就一分钟一分钟变老。本进程第一次铸根时清扫:名字恰是 `<前缀><六位随机>`、`lstat` 是目录、属主是当前用户、根内有 `.alive` 常规文件、
|
|
12
|
+
* 且 `.alive` 的 mtime 早于 {@link STALE_LOCAL_ROOT_AGE_MS}(60 分钟)的旧件,整根删 —— 规则只有这一条。
|
|
13
|
+
* 这管所有进程自己没 cleanup 的死法:根都留着,`.alive` 超过 60 分钟没更新之后,由下一轮第一次铸根时的清扫收。
|
|
14
|
+
* **不问进程号、不问主机名**:证据与文件系统同域,共享同一 tmp 的几个进程、几个容器(跨 pid 命名空间也一样)读到的是同一个答案 —— 谁的 `.alive` 在更新,谁的根就不被清。
|
|
15
|
+
* 根里没有 `.alive` 的(更早的版本铸的、运维手造的同形目录)一概不动,拿不准就不删。
|
|
16
|
+
* **限制**:心跳停了 60 分钟的活进程,它的根会被别的进程当作死进程的根清掉 —— 心跳停有两种:事件循环停转超过 60 分钟(被 `SIGSTOP` 暂停、容器被冻结、停在调试器断点上);
|
|
17
|
+
* 心跳连续 60 分钟写不进去(文件系统写满、被重挂成只读 —— 这一拍作罢、不抛、不报)。
|
|
18
|
+
* {@link ALIVE_TOUCH_INTERVAL_MS} 与 {@link STALE_LOCAL_ROOT_AGE_MS} 是源里的具名常量、读得到,**不是旋钮**(没有 env、没有参数改它们)。
|
|
19
|
+
* **`.alive` 静态为空,只给清扫看**:`loadLocalConfig` 只按名读 `config.d/<域>.json`、`config.d/remote-exec.json` 与 `.env`,列文件只列 `config.d/`,所以它不被自动读到;谁在 `.env` 里用 `file:` 显式引用它,读到的是空文件、被拒(`file:` 引用不接受去掉首尾空白后为空的文件,与引用一个不存在的文件同拒),
|
|
20
|
+
* 不会解析成一个值。心跳写进去的那一个字节是换行(空白),只存在于心跳那两步之间;截回失败或进程恰在两步之间死掉时它会留在文件里,但 `file:` 引用读到后会去掉首尾空白,留着一个换行的文件去掉首尾空白后仍为空,照样被拒。
|
|
21
|
+
* 心跳一拍可能卡在文件系统上(挂住的网络盘):上一拍还没做完时,到期的这一拍直接跳过(每个根一面在飞旗)—— 不叠写、不排队;挂住的那一拍做完(成功或失败)后旗复位,心跳照常。
|
|
22
|
+
* 还有四条边界:宿主进程活着时,它铸出而没 cleanup 的根一直带着新鲜的 `.alive`,清扫不碰(进程死后才开始变老);
|
|
23
|
+
* 宿主活着却没 `cleanup()` 的根,它的心跳定时器(`unref`,不拖住进程退出)每 {@link ALIVE_TOUCH_INTERVAL_MS} 开写一次、直到进程结束 —— 代价是一次一字节的小文件写(一拍一开一关,不占常驻的文件描述符),补偿是调 `cleanup()`(契约本就要求);
|
|
24
|
+
* 定时器取自 `node:timers`:只接管全局 `setInterval` 的伪计时器(vitest 的 `vi.useFakeTimers()` 默认如此)接管不了它;连 `timers` 模块一起接管、且先于本包首次 import 装上的伪计时器库会把这只心跳接过去,跑尽定时器的调用(如 `runAll`)会撞上它 ——
|
|
25
|
+
* 补偿是跑尽定时器之前先调 `cleanup()`(契约本就要求;它清掉的正是这只心跳);
|
|
26
|
+
* `mkdtemp` 与写 `.alive` 之间被杀的进程留下一个没有 `.alive` 的根,清扫不认它;
|
|
27
|
+
* ② **正常路径**:{@link removeLocalRoot}(`cleanup()` 调它)停这个根自己的心跳定时器、再删根(只删自己的根;不开不关任何文件;还在飞的一拍随后在已摘出目录的文件上做完、或打不开而落空,被吞掉)。
|
|
28
|
+
*
|
|
29
|
+
* 本模块不在进程上装任何监听:不碰 `exit`,也不碰 `SIGINT` / `SIGTERM` —— 宿主自己的 `exit` / 信号处理器(含排空、含装在铸根之后的)一概不受影响,
|
|
30
|
+
* 宿主的 `exit` 处理器里照样读得到根。死法的收口只有旧件清扫这一种,没有第二套机制;心跳定时器不是进程监听,且 `unref`,不拖住进程退出。
|
|
31
|
+
*
|
|
32
|
+
* 清扫是**尽力而为**:删不掉的(别的用户的、没权限的)留着,之后的清扫再试,绝不让一次清扫失败改变一次符合性结论。
|
|
33
|
+
* 本模块不读进程的钟:清扫的「现在」是刚铸出的根目录的 mtime(建 `.alive` 那一刻由文件系统打),心跳是在 `.alive` 上写一个字节再截回空、mtime 同样由文件系统打 ——
|
|
34
|
+
* 与它比的 `.alive` 的 mtime 和它的「现在」是同一只钟(文件系统的),所以进程自己的钟拨偏多少都不改变清扫的答案。
|
|
35
|
+
*/
|
|
36
|
+
import { constants, lstat, mkdtemp, open, readdir, rm, stat } from "node:fs/promises";
|
|
37
|
+
import { tmpdir } from "node:os";
|
|
38
|
+
import { join } from "node:path";
|
|
39
|
+
import { clearInterval, setInterval } from "node:timers"; // 心跳定时器取自模块,不取全局:只接管全局的伪计时器(vitest 的 `vi.useFakeTimers()` 默认如此)接管不了这只永不停的间隔(连模块一起接管的,见头注)
|
|
40
|
+
/** 临时根的目录名前缀;名字 = 前缀 + `mkdtemp` 的六位随机(清扫只认这一种形)。 */
|
|
41
|
+
export const LOCAL_ROOT_PREFIX = "settings-conformance-";
|
|
42
|
+
/** 根内的心跳文件名:静态为空的文件,活进程定时(同一个 inode 才写)写一个字节再截回空、mtime 由文件系统自己打;清扫只看它的 mtime 年龄。 */
|
|
43
|
+
export const LOCAL_ROOT_ALIVE_FILE = ".alive";
|
|
44
|
+
/** 心跳每一拍写进 `.alive` 的那一个字节:换行(空白)—— `file:` 引用读到后会去掉首尾空白再判空,所以它留在文件里多久,引用 `.alive` 都读到空 ⇒ 被拒,不会解析成值;写完立即截回空(文件静态为空,铸根时不写它)。 */
|
|
45
|
+
const ALIVE_BYTE = "\n";
|
|
46
|
+
/**
|
|
47
|
+
* 旧件的年龄线:60 分钟 —— 根里 `.alive` 的 mtime 早于它,清扫才删这个根。一个 case 的一条本地腿只要几毫秒、整套语料也只要几秒,60 分钟远长于一次正常的跑,
|
|
48
|
+
* 所以心跳停了这么久的根绝大多数是上一轮被收走的残件;线又短到清扫能及时收走它们,不让它们一天天攒。它必须远大于 {@link ALIVE_TOUCH_INTERVAL_MS},事件循环照常转的活进程的 `.alive` 才不会老到这条线。
|
|
49
|
+
* 具名常量,不是旋钮。
|
|
50
|
+
*/
|
|
51
|
+
export const STALE_LOCAL_ROOT_AGE_MS = 60 * 60 * 1000;
|
|
52
|
+
/** 心跳间隔:5 分钟 —— 活进程每隔这么久在根里的 `.alive` 上写一个字节再截回空(文件系统给它打新的 mtime)。它必须远小于 {@link STALE_LOCAL_ROOT_AGE_MS}(现为十二分之一),偶尔一拍慢了也不会让活进程的 `.alive` 老过年龄线。具名常量,不是旋钮。 */
|
|
53
|
+
export const ALIVE_TOUCH_INTERVAL_MS = 5 * 60 * 1000;
|
|
54
|
+
const OWN_NAME = new RegExp(`^${LOCAL_ROOT_PREFIX}[A-Za-z0-9]{6}$`);
|
|
55
|
+
/**
|
|
56
|
+
* 清扫 `dir` 下的旧临时根:名字恰是「前缀 + 六位字母数字」、`lstat` 是目录(符号链接与文件一概不动)、属主是当前用户(有 `getuid` 的平台)、
|
|
57
|
+
* 根内有 `.alive` 且 `lstat` 是常规文件(目录、符号链接都不算)、`nowMs - <.alive 的 mtime>` 超过 `maxAgeMs` ⇒ 整根删。没有 `.alive` 的一概不动。
|
|
58
|
+
* 不问进程号、不问主机名 —— 活性证据只有 `.alive` 的 mtime 年龄。返回被删的目录名。读不了 `dir` / 删不掉某一个 ⇒ 跳过,不抛。
|
|
59
|
+
*/
|
|
60
|
+
export async function sweepStaleLocalRoots(opts) {
|
|
61
|
+
const maxAgeMs = opts.maxAgeMs ?? STALE_LOCAL_ROOT_AGE_MS;
|
|
62
|
+
const uid = process.getuid?.();
|
|
63
|
+
const removed = [];
|
|
64
|
+
let names;
|
|
65
|
+
try {
|
|
66
|
+
names = await readdir(opts.dir);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return removed;
|
|
70
|
+
}
|
|
71
|
+
for (const name of names) {
|
|
72
|
+
if (!OWN_NAME.test(name))
|
|
73
|
+
continue;
|
|
74
|
+
const path = join(opts.dir, name);
|
|
75
|
+
try {
|
|
76
|
+
const st = await lstat(path);
|
|
77
|
+
if (!st.isDirectory() || (uid !== undefined && st.uid !== uid))
|
|
78
|
+
continue;
|
|
79
|
+
const alive = await lstat(join(path, LOCAL_ROOT_ALIVE_FILE)); // 没有 `.alive` ⇒ 抛 ENOENT ⇒ 跳过(没有活性证据,不删)
|
|
80
|
+
if (!alive.isFile() || opts.nowMs - alive.mtimeMs <= maxAgeMs)
|
|
81
|
+
continue;
|
|
82
|
+
await rm(path, { recursive: true, force: true });
|
|
83
|
+
removed.push(name);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// 尽力而为(见文件头):留着,之后的清扫再试
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return removed;
|
|
90
|
+
}
|
|
91
|
+
/** 本进程铸出、还没删的根 → 它的心跳。 */
|
|
92
|
+
const heartbeats = new Map();
|
|
93
|
+
/**
|
|
94
|
+
* 建 `.alive`:`O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW`(路径上已有任何东西 —— 符号链接也算 —— 就抛),`fstat` 记下 `(dev, ino)`,**关掉**再返回;
|
|
95
|
+
* `fstat` 失败时也关掉,抛的是 `fstat` 的错(关闭的错不盖它)。
|
|
96
|
+
*/
|
|
97
|
+
async function createAliveFile(path) {
|
|
98
|
+
const file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW);
|
|
99
|
+
let identity;
|
|
100
|
+
try {
|
|
101
|
+
const { dev, ino } = await file.stat({ bigint: true });
|
|
102
|
+
identity = { dev, ino };
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
await file.close().catch(() => { });
|
|
106
|
+
throw e;
|
|
107
|
+
}
|
|
108
|
+
await file.close();
|
|
109
|
+
return identity;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 心跳的一拍:按路径 `O_WRONLY | O_NOFOLLOW | O_NONBLOCK` 打开 `.alive`(不带 `O_CREAT`:文件或根已被删,这一拍 `ENOENT` 作罢,不把它们重建出来;符号链接 `ELOOP`、目录 `EISDIR`、没有读端的命名管道 `ENXIO`〔`O_NONBLOCK` 的用处:不然这个打开会一直等读端〕同样作罢),
|
|
113
|
+
* `fstat` 比 `(dev, ino)`:不等(换成了别的文件,含硬链接到别处的文件)⇒ 本拍不写;相等才写一个字节(换行)、再截回空 —— 两步都改变文件大小,所以都由文件系统给 mtime 打新戳;
|
|
114
|
+
* 文件静态为空,那一个字节只存在于这两步之间(截回失败或进程恰在两步之间死掉时会留着,留着也是空白,见头注)。无论哪条路都在 `finally` 里关掉这一拍自己打开的文件。
|
|
115
|
+
*/
|
|
116
|
+
async function beat(path, identity) {
|
|
117
|
+
const file = await open(path, constants.O_WRONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
118
|
+
try {
|
|
119
|
+
const { dev, ino } = await file.stat({ bigint: true });
|
|
120
|
+
if (dev !== identity.dev || ino !== identity.ino)
|
|
121
|
+
return;
|
|
122
|
+
await file.write(ALIVE_BYTE, 0);
|
|
123
|
+
await file.truncate(0);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await file.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** 定时器到期:上一拍还没做完就跳过;否则起一拍,失败吞掉(尽力而为:文件系统暂时写不了 / 根已被删 / `.alive` 已不是铸根时那个文件 ⇒ 这一拍作罢,下一拍再看),做完(成功或失败)旗复位。 */
|
|
130
|
+
function tick(path, heartbeat) {
|
|
131
|
+
if (heartbeat.beating)
|
|
132
|
+
return;
|
|
133
|
+
heartbeat.beating = true;
|
|
134
|
+
beat(path, heartbeat.identity)
|
|
135
|
+
.catch(() => { })
|
|
136
|
+
.finally(() => {
|
|
137
|
+
heartbeat.beating = false;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
let sweptThisProcess = false;
|
|
141
|
+
/**
|
|
142
|
+
* 铸一个临时根:根内建空文件 `.alive`、记下它的身份就关(本模块按路径建它只此一处)、起它的心跳定时器;本进程第一次铸根时先清扫旧件(见文件头 ①)。
|
|
143
|
+
* 铸出这个根之前任何一步失败(建 `.alive`、起定时器),都回滚到什么也没留下 —— 停定时器、删根,再把原错抛出去。返回根的绝对路径。
|
|
144
|
+
*/
|
|
145
|
+
export async function mintLocalRoot() {
|
|
146
|
+
const dir = tmpdir();
|
|
147
|
+
const root = await mkdtemp(join(dir, LOCAL_ROOT_PREFIX));
|
|
148
|
+
const alive = join(root, LOCAL_ROOT_ALIVE_FILE);
|
|
149
|
+
let started;
|
|
150
|
+
try {
|
|
151
|
+
const identity = await createAliveFile(alive); // 静态为空,心跳的那一个字节只在每一拍的两步之间存在
|
|
152
|
+
const heartbeat = { identity, beating: false, timer: setInterval(() => tick(alive, heartbeat), ALIVE_TOUCH_INTERVAL_MS) };
|
|
153
|
+
started = heartbeat;
|
|
154
|
+
heartbeat.timer.unref();
|
|
155
|
+
heartbeats.set(root, heartbeat);
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
if (started !== undefined)
|
|
159
|
+
clearInterval(started.timer);
|
|
160
|
+
await rm(root, { recursive: true, force: true }); // 没有 `.alive` 的根没人清得掉:建不成就不留这个根
|
|
161
|
+
throw e;
|
|
162
|
+
}
|
|
163
|
+
if (!sweptThisProcess) {
|
|
164
|
+
sweptThisProcess = true;
|
|
165
|
+
try {
|
|
166
|
+
await sweepStaleLocalRoots({ dir, nowMs: (await stat(root)).mtimeMs });
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// 读不到刚铸出的根的 mtime ⇒ 这一轮不清扫(尽力而为)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return root;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* 删一个临时根:先停它的心跳定时器,再只删这一个(删不掉就抛,留下的根等 `.alive` 超过 60 分钟没更新之后的旧件清扫收)。不开不关任何文件;还在飞的一拍不等它。
|
|
176
|
+
*/
|
|
177
|
+
export async function removeLocalRoot(root) {
|
|
178
|
+
const heartbeat = heartbeats.get(root);
|
|
179
|
+
if (heartbeat !== undefined) {
|
|
180
|
+
clearInterval(heartbeat.timer);
|
|
181
|
+
heartbeats.delete(root);
|
|
182
|
+
}
|
|
183
|
+
await rm(root, { recursive: true, force: true });
|
|
184
|
+
}
|
package/dist/cross-session.d.ts
CHANGED
|
@@ -50,11 +50,11 @@ export type CrossSessionInboundValue = (typeof CROSS_SESSION_INBOUND_VALUES)[num
|
|
|
50
50
|
export declare const CROSS_SESSION_INBOUND_UNSET: "default";
|
|
51
51
|
/** 文件层受理面:四个显式值 + `"default"`(未设)。 */
|
|
52
52
|
export declare const CrossSessionInboundSetting: z.ZodEnum<{
|
|
53
|
+
refuse: "refuse";
|
|
53
54
|
default: "default";
|
|
54
55
|
wake: "wake";
|
|
55
56
|
"next-turn": "next-turn";
|
|
56
57
|
hold: "hold";
|
|
57
|
-
refuse: "refuse";
|
|
58
58
|
}>;
|
|
59
59
|
export type CrossSessionInboundSettingValue = z.infer<typeof CrossSessionInboundSetting>;
|
|
60
60
|
/** 四值闭集(CC 逐字)。`"never"` = 不设期限。 */
|
|
@@ -64,8 +64,8 @@ export type DialogExpiryValue = (typeof DIALOG_EXPIRY_VALUES)[number];
|
|
|
64
64
|
export declare const DIALOG_EXPIRY_DEFAULT: DialogExpiryValue;
|
|
65
65
|
/** 文件层受理面:四个值 + `"default"`(未设 ⇒ 落 {@link DIALOG_EXPIRY_DEFAULT})。 */
|
|
66
66
|
export declare const DialogExpirySetting: z.ZodEnum<{
|
|
67
|
-
never: "never";
|
|
68
67
|
default: "default";
|
|
68
|
+
never: "never";
|
|
69
69
|
"60s": "60s";
|
|
70
70
|
"5m": "5m";
|
|
71
71
|
"10m": "10m";
|
|
@@ -78,15 +78,15 @@ export type DialogExpirySettingValue = z.infer<typeof DialogExpirySetting>;
|
|
|
78
78
|
*/
|
|
79
79
|
export declare const CrossSessionSettings: z.ZodObject<{
|
|
80
80
|
crossSessionInbound: z.ZodOptional<z.ZodEnum<{
|
|
81
|
+
refuse: "refuse";
|
|
81
82
|
default: "default";
|
|
82
83
|
wake: "wake";
|
|
83
84
|
"next-turn": "next-turn";
|
|
84
85
|
hold: "hold";
|
|
85
|
-
refuse: "refuse";
|
|
86
86
|
}>>;
|
|
87
87
|
dialogExpiry: z.ZodOptional<z.ZodEnum<{
|
|
88
|
-
never: "never";
|
|
89
88
|
default: "default";
|
|
89
|
+
never: "never";
|
|
90
90
|
"60s": "60s";
|
|
91
91
|
"5m": "5m";
|
|
92
92
|
"10m": "10m";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 包内私有(不进主桶):**按声明键投影的快照,与校验合在一起** —— 校验器(zod)读一个对象时只读它声明的键、剥掉其余;要保护它别读到 `Object.prototype` 上被污染的继承值、别碰会抛的 getter,
|
|
3
|
+
* 就得给它一份**自有键副本**。整份深拷({@link captureJson})会把「校验器根本不读」的键(没声明的键里放着 `Date` / `Map` / 函数 / 环 / 一个会抛的 getter)也读进来 ——
|
|
4
|
+
* 一个本来读得通的 body 变成整体读不出。所以:**只捕获校验器会读的键**,而「会读哪些键」随联合选哪一臂而定,所以投影与校验一起走({@link parseDeclared})。
|
|
5
|
+
*
|
|
6
|
+
* · 对象形(`z.object`,没有 catchall —— 即「剥掉没声明的键」那一种):只读它声明的键(`shape` 的键),每个声明键的值按**对应子 schema** 递归;没声明的键一个都不读不拷;
|
|
7
|
+
* · **判别联合**:先按判别键(与声明键同一只描述符读)选臂,**只投影选中那一臂**的声明键;判别键**只读一次** —— 选臂的那个词就是快照里交给校验器的那个词;
|
|
8
|
+
* 判别键缺席 / 不是任何一臂的词 ⇒ 只交过去判别键(校验器只看它就拒);
|
|
9
|
+
* · **普通联合**(顶层):按序逐臂 —— 一臂一份只含该臂声明键的快照、**只拿该臂的 schema 校验**,该臂通过才接受、**第一臂通过即止**,没通过才轮到下一臂(与 zod 按序试臂一致;不取并集;
|
|
10
|
+
* 拿整个联合去校验的话,后臂会用默认值接受前一臂的残缺投影);**互斥联合**(`z.xor`)所有臂都走一遍、恰一臂通过才接受(两臂都通过 zod 拒,这里同样拒)。
|
|
11
|
+
* 嵌在对象里的普通联合、以及套着包装层的联合当不透明:整个值深拷、对整只 schema 校验;
|
|
12
|
+
* · `unknown` / `any`(投影走到的位置上):校验器自己不看它的内容 ⇒ 调用方的原值原样交过去(后面接的 transform 是包自己写的);
|
|
13
|
+
* · 其余(标量、`record`、数组、`strict` 的对象 —— 校验器要读全部键):整个值深拷(不透明)。
|
|
14
|
+
* 声明键的律与内核同:自有但不可枚举 ⇒ 读不成纯数据、整份拒({@link NOT_PLAIN_DATA}),**不当缺席**;自有 getter 抛 / 陷阱抛 ⇒ {@link UNINSPECTABLE};只靠继承 ⇒ 缺席。
|
|
15
|
+
* 任何一份快照拿不到(读不出 / 拒)⇒ 整个读不出(不再试后面的臂:v14.6.0 里校验器在那里读到的会抛)。守护在 {@link parseDeclared} 入口一处:门的 schema 内省、快照递归与校验同在里面。
|
|
16
|
+
*
|
|
17
|
+
* **门 = 闭集白名单**(`admit`,每只 schema 第一次进来时走一遍整棵 schema 树,通过的记下;递归 schema 按「节点 × 位置」只判一次):只放行**与 zod 同答**(判决与数据,对 JSON 输入)的节点种类
|
|
18
|
+
* —— 每一种都按 zod 4.4 / 4.6 的读法推过、有对 zod 逐例比判决与数据的钉;反例搜索不是穷举,找到不同答的形就从放行单拿掉 ——
|
|
19
|
+
* 叶子(不跑用户函数、没有子 schema)· 不跑看得见输入的函数的包装层(`optional` / `nullable` / `default` / `prefault`)· 后处理 `.transform(f)`(`f` 看到的是里面那只 schema 的输出,两边同一份)·
|
|
20
|
+
* 对象(声明键不是符号、也不叫寻常对象从 `Object.prototype` 继承来的名字 —— zod 在原值上读到继承的成员,投影读成缺席)· 判别联合(不带 `unionFallback`)· 普通 / 互斥联合 · `record` · 数组 ·
|
|
21
|
+
* 投影走到的位置上的 `unknown` / `any`。对象 / 数组 / record 上挂的检查(refine / superRefine / 长度 …)看到的是 zod 自己铸的新输出,两边同一份。
|
|
22
|
+
* 其余一律**响亮拒**(抛 `parseDeclared: unsupported schema shape — …`;这是包自己的 schema 选错了读法,不是调用方的值读不出):`.catch`(兜底函数看到整个原值 —— zod ≥ 4.6 交 `ctx.input`)·
|
|
23
|
+
* 输入侧变换(`z.preprocess`、字段位上单独的 `z.transform`、两侧都是 schema 的管道)· `z.custom`(谓词看到的是副本)· 深拷节点之下的 `unknown` / `any`(副本会进输出;passthrough 对象同因)·
|
|
24
|
+
* 声明键叫继承名 / 是符号(有限键集 record 的键同此)· loose record · 顶层普通联合自己挂的检查 · 带 `unionFallback` 的判别联合 · `tuple` / `intersection` / `map` / `set` / `lazy` / `readonly` …(没推过)。
|
|
25
|
+
* 不逐形补黑名单:新形默认拒。校验只问判决、不要句子(`evaluate` 给 zod 逐次 `error` 参数):副本的原型不是 `Object.prototype`,zod 拼拒句时会读副本上用户写的自有 `constructor`。
|
|
26
|
+
*/
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
/**
|
|
29
|
+
* 对 `value` 校验的守护读法(`schema` 的 safeParse):校验器读的是投影后的自有键快照,不是调用方的对象。读不出 / 不是纯数据 / 校验不过 ⇒ `{ ok: false }`(不抛);
|
|
30
|
+
* 联合按 {@link evaluate} 的语义逐臂。`schema` 不在白名单里(见文件头)⇒ 抛 `parseDeclared: unsupported schema shape — …`(不论输入)。
|
|
31
|
+
* **守护边界就在这里一处**(内核的 `inspect`):白名单门对 schema 的内省(zod 的惰性属性 —— 声明键表、判别联合的 `propValues` —— 第一次被读时才物化,物化时 zod 用字面量描述符调
|
|
32
|
+
* `defineProperty`、往 `{}` 上按键名累加:`Object.prototype` 被污染成 `get` / `set` / `value` / 判别键名时,物化在这一步抛)、投影快照的递归(递归 schema × 很深的数据会爆栈)与校验,
|
|
33
|
+
* 全在同一只守护里 —— 抛了 ⇒ `{ ok: false }`(与 v14.6.0 那一层守护同向:读口答「读不出」,不把引擎的错 / 嵌入方的抛出值带出去)。
|
|
34
|
+
* 「不支持」是门**交回**的判语、在守护外抛:它是包自己的 schema 选错了读法,与输入无关。
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseDeclared<S extends z.ZodType>(schema: S, value: unknown): {
|
|
37
|
+
readonly ok: true;
|
|
38
|
+
readonly data: z.output<S>;
|
|
39
|
+
} | {
|
|
40
|
+
readonly ok: false;
|
|
41
|
+
};
|