dsh-expert-group 0.1.1
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/LICENSE +38 -0
- package/README.md +355 -0
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/assets/notification-whoosh.wav +0 -0
- package/bin/prompt-inspector.mjs +63 -0
- package/client-bundle.js +14891 -0
- package/client-expert-group-model.js +426 -0
- package/client-expert-group-sidebar.js +5401 -0
- package/client-freshness.js +364 -0
- package/client-model-config.js +2155 -0
- package/client-notifications.js +38 -0
- package/client.js +6487 -0
- package/cordis.patch.yml +10 -0
- package/fanout-host.js +1507 -0
- package/fanout.js +2981 -0
- package/host/composition.js +155 -0
- package/host/expert-group-runtime.js +822 -0
- package/host/expert-group-snapshot.js +450 -0
- package/host/fanout-runtime.js +822 -0
- package/host/handoff.js +80 -0
- package/host/lineage.js +274 -0
- package/host/notifications.js +189 -0
- package/host/persistence-health.js +389 -0
- package/host/recovery.js +242 -0
- package/host/rpc-transport.js +573 -0
- package/host/settings-runtime.js +168 -0
- package/host/settings-source.js +265 -0
- package/host/storage-binding.js +94 -0
- package/host/system-toast.js +916 -0
- package/image-routing.js +263 -0
- package/index.js +9327 -0
- package/install.ps1 +219 -0
- package/orchestrator.js +239 -0
- package/package.json +142 -0
- package/prompt-contracts.js +148 -0
- package/prompt-inspector.js +584 -0
- package/prompt-sidecar.js +610 -0
- package/resource-budget.js +540 -0
- package/role-registry.js +286 -0
- package/rpc-host.js +310 -0
- package/scripts/assemble-client.mjs +605 -0
- package/scripts/build.mjs +124 -0
- package/shared.js +4003 -0
- package/storage.js +190 -0
- package/uninstall.ps1 +47 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 发布 client bundle 的确定性装配(构建边界)。
|
|
4
|
+
*
|
|
5
|
+
* 背景:DSH 的客户端模块图只加载 package exports["./client"] 指向的**一个 classic
|
|
6
|
+
* bundle**,浏览器 classic script 又不支持 `import`/`export`,所以 freshness/RPC 的
|
|
7
|
+
* ESM 权威实现(client-freshness.js)不能直接出现在发布产物里,必须由本脚本在构建期
|
|
8
|
+
* 投影成 classic 片段。
|
|
9
|
+
*
|
|
10
|
+
* 本脚本是唯一的构建边界,做三件事:
|
|
11
|
+
* 1. **生成**:把 client-freshness.js 投影成 classic 片段(去掉行首 `export ` 前缀 +
|
|
12
|
+
* 追加"检查点 + ModuleLoader 登记"尾部),排在唯一 descriptor 之前执行。投影不改写
|
|
13
|
+
* 任何函数体,遇到无法识别的 ESM 语法直接抛错,绝不产出"半投影"。
|
|
14
|
+
* 2. **门禁**:校验发布 bundle 的装载契约(投影位置、登记语句、client.js 的转发 seam、
|
|
15
|
+
* descriptor 里不得再有第二份实现)与 classic script 语法(用 Script 目标编译一次,
|
|
16
|
+
* 浏览器就是按这个目标解析它的)。
|
|
17
|
+
* 3. **行为核对**:把刚生成的投影片段放进最小 global 替身真实执行一次,用同一份一致性
|
|
18
|
+
* 脚本(`probeFreshnessFace`)逐项比对 RPC envelope、错误映射、取消、session 切换、
|
|
19
|
+
* revision 与 dispose 的行为;漂移会让 `pnpm build` 直接失败,而不是留到运行期。
|
|
20
|
+
*
|
|
21
|
+
* ## client.js 内联 freshness 回退的评估结论(2026-10 审核 · ticket 13)
|
|
22
|
+
*
|
|
23
|
+
* client.js 曾经内联一份与 client-freshness.js 同语义的 fallback(函数体约 12KB),
|
|
24
|
+
* 投影存在之后它只剩"宿主没有该模块"这一条路径。评估结论:**在最低宿主契约下不需要,
|
|
25
|
+
* 已删除**,理由与收益:
|
|
26
|
+
*
|
|
27
|
+
* 1. **契约**:package.json peerDependencies 下限是 `@deepseek-ai/dsh-client-modules`
|
|
28
|
+
* `^0.2.0-rc.1`,README 声明的目标运行时同为 `0.2.0-rc.1`;宿主只加载
|
|
29
|
+
* exports["./client"] 指向的装配产物,而该产物恒含本脚本生成的投影片段,且投影排在
|
|
30
|
+
* 唯一 descriptor 之前执行,因此 `require("dsh-expert-group/client-freshness")`
|
|
31
|
+
* 必然命中同一张模块表——fallback 在生产路径上不可达。
|
|
32
|
+
* 2. **服务对象**:只有"直载 client.js"这一契约外用法会走到 fallback(client.js 只是
|
|
33
|
+
* 装配输入,没有任何宿主会单独 serve 它);包内测试替身
|
|
34
|
+
* (test-support/client-bundle.mjs)本来就登记同一份共享实现。
|
|
35
|
+
* 3. **风险取舍**:唯一真实风险是"投影缺失时静默降级成另一套实现"——那正是双份代码
|
|
36
|
+
* 各自漂移的来源。因此 client.js 改成 fail-closed 的转发壳:共享模块缺失直接抛
|
|
37
|
+
* `freshness 共享模块未装载`,由门禁(DESCRIPTOR_SEAM_MARKERS)与行为核对兜住。
|
|
38
|
+
* 4. **收益**:client.js 及其内联于 bundle 的副本各减约 10KB(安装面不再双份),
|
|
39
|
+
* freshness 行为仍由唯一实现 + 构建期行为核对 + test/client-shared-boundary.test.mjs
|
|
40
|
+
* 共同守住。
|
|
41
|
+
*/
|
|
42
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
43
|
+
import { dirname, join, resolve } from "node:path";
|
|
44
|
+
import { fileURLToPath } from "node:url";
|
|
45
|
+
|
|
46
|
+
const scriptPath = fileURLToPath(import.meta.url);
|
|
47
|
+
const root = resolve(dirname(scriptPath), "..");
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 浏览器安全的 classic IIFE 源,按数组顺序执行。
|
|
51
|
+
* 这些片段各自把实现挂到 `globalThis[Symbol.for("dsh-expert-group/<name>")]`,
|
|
52
|
+
* client.js 的 factory 只从那里取回实现,因此必须排在唯一 descriptor 之前。
|
|
53
|
+
*/
|
|
54
|
+
const IIFE_SOURCES = Object.freeze([
|
|
55
|
+
"client-expert-group-model.js",
|
|
56
|
+
"client-expert-group-sidebar.js",
|
|
57
|
+
// 通知协调模块必须与其它浏览器安全 IIFE 一起先于唯一 descriptor 执行:
|
|
58
|
+
// client.js 的 factory 只从 globalThis 取回这份实现,不再自带通知队列/轮询逻辑。
|
|
59
|
+
"client-notifications.js",
|
|
60
|
+
// 「模型配置」区域的唯一纯数据流实现(provider 目录装配、草稿/路径操作、
|
|
61
|
+
// 七档 reasoningEfforts 映射、继承/恢复默认、revision 冲突与角色联动守卫)。
|
|
62
|
+
// 同样先于 descriptor 执行:client.js 只从 globalThis 取回它并负责渲染。
|
|
63
|
+
"client-model-config.js",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* freshness/RPC 的 ESM 权威实现。构建期投影成 classic 片段,让真实发布 bundle 也通过
|
|
68
|
+
* ModuleLoader seam 装载共享实现;必须排在唯一 descriptor 之前,登记才来得及生效。
|
|
69
|
+
*/
|
|
70
|
+
const FRESHNESS_SOURCE = "client-freshness.js";
|
|
71
|
+
|
|
72
|
+
/** 唯一 ModuleLoader descriptor(client.js)始终最后执行,避免 HMR 留下重复子模块工厂。 */
|
|
73
|
+
const DESCRIPTOR_SOURCE = "client.js";
|
|
74
|
+
|
|
75
|
+
const sourceNames = Object.freeze([...IIFE_SOURCES, FRESHNESS_SOURCE, DESCRIPTOR_SOURCE]);
|
|
76
|
+
const outputName = "client-bundle.js";
|
|
77
|
+
const banner = "/* 此文件由 scripts/assemble-client.mjs 生成,请勿手工编辑。 */\n";
|
|
78
|
+
|
|
79
|
+
/** 字符串形式的投影尾部语句:生成与校验共用同一份常量,避免两边写法漂移。 */
|
|
80
|
+
const CHECKPOINT_STATEMENT = "global[Symbol.for(CLIENT_FRESHNESS_GLOBAL_KEY)] = __DEG_CLIENT_FRESHNESS_DESCRIPTOR__.factory();";
|
|
81
|
+
const REGISTER_STATEMENT = "registerClientFreshnessModule(global.__ModuleLoader__);";
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 投影必须保留的顶层导出:客户端装载路径依赖它们,缺一个就说明权威实现改了契约。
|
|
85
|
+
* (descriptor 与登记函数是投影尾部直接引用的名字,模块 id / 检查点键是相邻契约。)
|
|
86
|
+
*/
|
|
87
|
+
const REQUIRED_FRESHNESS_EXPORTS = Object.freeze([
|
|
88
|
+
"CLIENT_FRESHNESS_MODULE_ID",
|
|
89
|
+
"CLIENT_FRESHNESS_GLOBAL_KEY",
|
|
90
|
+
"__DEG_CLIENT_FRESHNESS_DESCRIPTOR__",
|
|
91
|
+
"registerClientFreshnessModule",
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 与 client-freshness.js 的 CLIENT_FRESHNESS_MODULE_ID 导出值一致的模块表 id:
|
|
96
|
+
* descriptor 的 require 契约与投影的登记 id 都必须是它(两处都由此常量门禁)。
|
|
97
|
+
*/
|
|
98
|
+
const FRESHNESS_MODULE_ID = "dsh-expert-group/client-freshness";
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* client.js 必须保留的转发 seam 标记:共享实现只存在于 client-freshness.js,
|
|
102
|
+
* 客户端只许转发。标记消失说明装载路径被改坏(例如有人又写回内联实现或删掉
|
|
103
|
+
* fail-closed 分支),构建必须直接失败。
|
|
104
|
+
*/
|
|
105
|
+
const DESCRIPTOR_SEAM_MARKERS = Object.freeze([
|
|
106
|
+
"function requireFreshnessSurface() {",
|
|
107
|
+
// fail-closed 文案:共享模块缺失时必须显式抛错,绝不退回第二份实现。
|
|
108
|
+
"freshness 共享模块未装载",
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* descriptor 里禁止再次出现的 freshness 实现声明(内联副本已随 ticket 13 删除)。
|
|
113
|
+
* 匹配"声明"而不是裸名字,避免注释里提到历史函数名就误报。
|
|
114
|
+
*/
|
|
115
|
+
const FORBIDDEN_INLINE_DECLARATIONS = Object.freeze([
|
|
116
|
+
"function createInlineFreshnessBoundary(",
|
|
117
|
+
"async function readSettingsCommitOutcomeInline(",
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
/** 与 client.js / client-freshness.js 发布值一致的 RPC 路由常量(行为核对脚本注入用)。 */
|
|
121
|
+
const RPC_TARGET = Object.freeze({
|
|
122
|
+
path: "/api/dsh-expert-group",
|
|
123
|
+
channel: "/dsh-expert-group",
|
|
124
|
+
endpoint: "rpc",
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/** 行为核对脚本的兜底草稿归一:真实调用方注入角色表相关实现,这里只需要同一份恒等函数。 */
|
|
128
|
+
const identityDraft = (value) => value;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 把 ESM 权威实现投影成 classic 片段。
|
|
132
|
+
*
|
|
133
|
+
* 只做逐行的机械改写:行首 `export ` 前缀去掉,其余字节保持不变。任何其它 ESM 语法
|
|
134
|
+
* (`export default`、`export {}`、`import ...`)都无法在 classic script 里表达,
|
|
135
|
+
* 直接抛错让构建失败,而不是产出一个宿主解析不了、只会静默回落 fallback 的 bundle。
|
|
136
|
+
*
|
|
137
|
+
* @param {string} source - client-freshness.js 的源码文本。
|
|
138
|
+
* @returns {{ body: string, exports: readonly string[] }}
|
|
139
|
+
*/
|
|
140
|
+
export function projectClientFreshnessSource(source) {
|
|
141
|
+
const exported = [];
|
|
142
|
+
const lines = [];
|
|
143
|
+
for (const line of source.split("\n")) {
|
|
144
|
+
if (!line.startsWith("export ")) {
|
|
145
|
+
lines.push(line);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const declaration = line.slice("export ".length);
|
|
149
|
+
exported.push(declaredNameOf(declaration));
|
|
150
|
+
lines.push(declaration);
|
|
151
|
+
}
|
|
152
|
+
const body = lines.join("\n");
|
|
153
|
+
// 注释/字符串里出现 import/export 不算违规,所以按"行首 ESM 关键字"判定。
|
|
154
|
+
const leftover = /(?:^|\n)[ \t]*(?:import|export)\b/.exec(body);
|
|
155
|
+
if (leftover !== null) {
|
|
156
|
+
throw new Error("client-freshness.js 投影后仍残留 ESM 语法: " + leftover[0].trim());
|
|
157
|
+
}
|
|
158
|
+
for (const name of REQUIRED_FRESHNESS_EXPORTS) {
|
|
159
|
+
if (!exported.includes(name)) {
|
|
160
|
+
throw new Error("client-freshness.js 缺少必需导出 " + name + "(client.js 的装载路径依赖它)");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return Object.freeze({ body, exports: Object.freeze(exported) });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 从 `const X = ...` / `async function X(...)` 这类声明文本里取出声明的名字。 */
|
|
167
|
+
function declaredNameOf(declaration) {
|
|
168
|
+
const match = /^(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)/.exec(declaration);
|
|
169
|
+
if (match === null) throw new Error("无法解析导出声明,请改用可投影的导出形式: " + declaration.trim());
|
|
170
|
+
return match[1];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 渲染 freshness 的 classic 投影片段。
|
|
175
|
+
*
|
|
176
|
+
* 尾部两条语句是发布 bundle 的真实装载路径:
|
|
177
|
+
* - 检查点:把同一份导出面挂到 globalThis 的公开 Symbol,测试与相邻片段据此证明
|
|
178
|
+
* "真实 bundle 里跑的就是权威实现",而不是 client.js 的内联副本;
|
|
179
|
+
* - 登记:`registerClientFreshnessModule` 内部按加载器实例幂等(DSH 的 register 对重复
|
|
180
|
+
* id 是抛错语义),因此 HMR 只失效主插件 id 后重新执行同一份 bundle 不会打断装载。
|
|
181
|
+
*
|
|
182
|
+
* @param {{ name: string, source: string }} options
|
|
183
|
+
*/
|
|
184
|
+
export function renderClientFreshnessSegment({ name, source }) {
|
|
185
|
+
const projection = projectClientFreshnessSource(source);
|
|
186
|
+
return [
|
|
187
|
+
"/* source: " + name + " */",
|
|
188
|
+
"/* classic 投影:来自源文件的机械导出改写,权威实现仍是 " + name + "。 */",
|
|
189
|
+
"(function installClientFreshness(global) {",
|
|
190
|
+
'"use strict";',
|
|
191
|
+
projection.body.trimEnd(),
|
|
192
|
+
"",
|
|
193
|
+
"/** 投影尾部(构建期生成):公开检查点 + ModuleLoader 登记。 */",
|
|
194
|
+
CHECKPOINT_STATEMENT,
|
|
195
|
+
REGISTER_STATEMENT,
|
|
196
|
+
"})(globalThis);",
|
|
197
|
+
"",
|
|
198
|
+
].join("\n");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 把值收敛成可 JSON 比较的快照:undefined / 函数 / 循环引用换成显式标记,对象键排序。 */
|
|
202
|
+
function freezeForCompare(value, seen = new WeakSet()) {
|
|
203
|
+
if (value === undefined) return { $type: "undefined" };
|
|
204
|
+
if (typeof value === "function") return { $type: "function" };
|
|
205
|
+
if (value === null || typeof value !== "object") return value;
|
|
206
|
+
if (seen.has(value)) return { $type: "cycle" };
|
|
207
|
+
seen.add(value);
|
|
208
|
+
if (Array.isArray(value)) return value.map((item) => freezeForCompare(item, seen));
|
|
209
|
+
const snapshot = {};
|
|
210
|
+
for (const key of Object.keys(value).sort()) snapshot[key] = freezeForCompare(value[key], seen);
|
|
211
|
+
return snapshot;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 收敛抛出的错误:只保留可比较的字段(message/name/code/status/currentRevision)。 */
|
|
215
|
+
function describeError(error) {
|
|
216
|
+
return {
|
|
217
|
+
name: typeof error?.name === "string" ? error.name : null,
|
|
218
|
+
message: typeof error?.message === "string" ? error.message : null,
|
|
219
|
+
code: error?.code ?? null,
|
|
220
|
+
status: error?.status ?? null,
|
|
221
|
+
currentRevision: error?.currentRevision ?? null,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 收敛 AbortSignal:只记录是否已被取消,不把宿主对象带进比较。 */
|
|
226
|
+
function describeSignal(signal) {
|
|
227
|
+
return { aborted: signal?.aborted === true };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 收敛 fetch-like 响应:ok / status / body;json() 抛错时记录错误而不是让脚本中断。 */
|
|
231
|
+
async function describeResponse(response) {
|
|
232
|
+
if (response === null || typeof response !== "object") return { $type: "invalid-response", value: String(response) };
|
|
233
|
+
let body;
|
|
234
|
+
try {
|
|
235
|
+
body = await response.json();
|
|
236
|
+
} catch (error) {
|
|
237
|
+
body = { $invalidJson: describeError(error) };
|
|
238
|
+
}
|
|
239
|
+
return { ok: response.ok === true, status: response.status, body: freezeForCompare(body) };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 收敛 Connection rpc.call 的实参:channel/endpoint/request/signal 与实参个数。 */
|
|
243
|
+
function describeChannelCall(args) {
|
|
244
|
+
return {
|
|
245
|
+
channel: args[0],
|
|
246
|
+
endpoint: args[1],
|
|
247
|
+
request: args[2],
|
|
248
|
+
signal: describeSignal(args[3]),
|
|
249
|
+
argumentCount: args.length,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 收敛 legacy fetch 的实参:url 与 init 的关键字段(含 signal 透传)。 */
|
|
254
|
+
function describeFetchCall(url, init) {
|
|
255
|
+
return {
|
|
256
|
+
url,
|
|
257
|
+
method: init?.method,
|
|
258
|
+
credentials: init?.credentials,
|
|
259
|
+
headers: init?.headers,
|
|
260
|
+
body: init?.body,
|
|
261
|
+
signal: describeSignal(init?.signal),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* freshness 面的一致性脚本(构建门禁与测试共用的唯一实现)。
|
|
267
|
+
*
|
|
268
|
+
* 用固定输入依次驱动一个"实现面"上的公共入口,并把每步结果收敛成可 JSON 比较的快照:
|
|
269
|
+
* RPC envelope 归一、错误 code 映射、取消(AbortSignal 透传与错误传播)、
|
|
270
|
+
* session 切换、revision/cursor 单调性、dispose 后的迟到响应。
|
|
271
|
+
* 脚本不读写外部状态、不使用时间与随机数,因此同一实现在任何一次运行里的日志逐字节相同。
|
|
272
|
+
*
|
|
273
|
+
* @param {object} face - 至少提供 rpcErrorStatus / normalizePluginRpcResult / requestPluginRpc /
|
|
274
|
+
* createClientFreshnessBoundary / readSettingsCommitOutcome。
|
|
275
|
+
* @returns {Promise<Array<{ label: string, value: unknown }>>}
|
|
276
|
+
*/
|
|
277
|
+
export async function probeFreshnessFace(face) {
|
|
278
|
+
const log = [];
|
|
279
|
+
const record = (label, value) => { log.push({ label, value: freezeForCompare(value) }); };
|
|
280
|
+
const recordRun = async (label, run) => {
|
|
281
|
+
try {
|
|
282
|
+
record(label, { resolved: await run() });
|
|
283
|
+
} catch (error) {
|
|
284
|
+
record(label, { rejected: describeError(error) });
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// ── 1. 错误 code → status 映射 ─────────────────────────────────────────────
|
|
289
|
+
const codes = ["settings-conflict", "session-not-found", "not-found", "forbidden", "connection-required", "gateway/cancelled", "bad-request", "internal", "", undefined];
|
|
290
|
+
record("error-status-map", codes.map((code) => [String(code), face.rpcErrorStatus({ code })]));
|
|
291
|
+
record("error-status-map:no-error", face.rpcErrorStatus());
|
|
292
|
+
|
|
293
|
+
// ── 2. Connection envelope 归一(两条传输的错误字段必须一致)───────────────
|
|
294
|
+
const envelopes = [
|
|
295
|
+
{ ok: true, value: { ok: true, settings: { provider: "p" }, revision: 3 } },
|
|
296
|
+
{ ok: false, error: { code: "settings-conflict", message: "冲突", details: { revision: 4 } } },
|
|
297
|
+
{ ok: false, error: { code: "gateway/cancelled", message: "已取消" }, status: 499 },
|
|
298
|
+
{ ok: false, error: { code: "unknown-code", message: "未知" } },
|
|
299
|
+
{ ok: true },
|
|
300
|
+
{},
|
|
301
|
+
];
|
|
302
|
+
for (const [index, envelope] of envelopes.entries()) {
|
|
303
|
+
await recordRun("envelope#" + index, async () => await describeResponse(face.normalizePluginRpcResult(envelope)));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── 3. requestPluginRpc:Connection channel / legacy fetch / 取消 ──────────
|
|
307
|
+
const channelCalls = [];
|
|
308
|
+
const channelConnection = {
|
|
309
|
+
rpc: {
|
|
310
|
+
async call(...args) {
|
|
311
|
+
channelCalls.push(describeChannelCall(args));
|
|
312
|
+
return envelopes[0];
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
const controller = new AbortController();
|
|
317
|
+
await recordRun("request:channel", async () => await describeResponse(
|
|
318
|
+
await face.requestPluginRpc(channelConnection, { action: "meta" }, { signal: controller.signal }, null, RPC_TARGET),
|
|
319
|
+
));
|
|
320
|
+
record("request:channel-calls", channelCalls);
|
|
321
|
+
|
|
322
|
+
const fetchCalls = [];
|
|
323
|
+
const legacyFetch = async (url, init) => {
|
|
324
|
+
fetchCalls.push(describeFetchCall(url, init));
|
|
325
|
+
return { ok: false, status: 409, async json() { return { ok: false, error: { code: "settings-conflict" } }; } };
|
|
326
|
+
};
|
|
327
|
+
await recordRun("request:legacy-fetch", async () => await describeResponse(
|
|
328
|
+
await face.requestPluginRpc(undefined, { action: "meta" }, { signal: controller.signal }, legacyFetch, RPC_TARGET),
|
|
329
|
+
));
|
|
330
|
+
record("request:legacy-fetch-calls", fetchCalls);
|
|
331
|
+
|
|
332
|
+
// 没有 fetch 实现(旧宿主且没有 Connection)时必须抛出明确错误;显式传 null 避免
|
|
333
|
+
// 落到默认实现上真的发请求。
|
|
334
|
+
await recordRun("request:no-transport", async () => await face.requestPluginRpc(undefined, { action: "meta" }, {}, null, RPC_TARGET));
|
|
335
|
+
|
|
336
|
+
// 取消:传输层 reject 必须原样传播,不能被包成 envelope 或吞掉。
|
|
337
|
+
const cancelController = new AbortController();
|
|
338
|
+
const cancellingConnection = {
|
|
339
|
+
rpc: {
|
|
340
|
+
async call() {
|
|
341
|
+
cancelController.abort();
|
|
342
|
+
const error = new Error("已取消");
|
|
343
|
+
error.name = "AbortError";
|
|
344
|
+
error.code = "gateway/cancelled";
|
|
345
|
+
throw error;
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
await recordRun("request:cancel-rejects", async () => await describeResponse(
|
|
350
|
+
await face.requestPluginRpc(cancellingConnection, { action: "meta" }, { signal: cancelController.signal }, null, RPC_TARGET),
|
|
351
|
+
));
|
|
352
|
+
|
|
353
|
+
// ── 4. freshness boundary:session / revision / cursor / dispose ───────────
|
|
354
|
+
const updates = [];
|
|
355
|
+
const boundary = face.createClientFreshnessBoundary({
|
|
356
|
+
sessionId: "root-1",
|
|
357
|
+
onUpdate: (state) => updates.push({ kind: state.kind, state: state.state }),
|
|
358
|
+
});
|
|
359
|
+
record("boundary:initial", boundary.snapshot());
|
|
360
|
+
|
|
361
|
+
const settingsFirst = boundary.beginRequest("settings", "global");
|
|
362
|
+
const settingsLatest = boundary.beginRequest("settings", "global");
|
|
363
|
+
record("settings:latest", boundary.acceptSettingsSnapshot({ revision: 2, value: { provider: "new" }, requestToken: settingsLatest }));
|
|
364
|
+
record("settings:superseded", boundary.acceptSettingsSnapshot({ revision: 3, value: { provider: "stale" }, requestToken: settingsFirst }));
|
|
365
|
+
record("settings:older-revision", boundary.acceptSettingsSnapshot({ revision: 1, value: { provider: "older" } }));
|
|
366
|
+
record("settings:duplicate", boundary.acceptSettingsSnapshot({ revision: 2, value: { provider: "new" } }));
|
|
367
|
+
record("settings:invalid-revision", boundary.acceptSettingsSnapshot({ revision: 1.5, value: { provider: "fraction" } }));
|
|
368
|
+
record("settings:is-current", [
|
|
369
|
+
boundary.isCurrent(settingsLatest),
|
|
370
|
+
boundary.isCurrent(settingsFirst),
|
|
371
|
+
boundary.isCurrent(undefined),
|
|
372
|
+
boundary.isCurrent(42),
|
|
373
|
+
]);
|
|
374
|
+
|
|
375
|
+
const catalogFirst = boundary.beginRequest("catalog", "global");
|
|
376
|
+
const catalogLatest = boundary.beginRequest("catalog", "global");
|
|
377
|
+
record("catalog:latest", boundary.acceptCatalogResponse({ generation: 2, groups: [{ id: "new" }], requestToken: catalogLatest }));
|
|
378
|
+
record("catalog:superseded", boundary.acceptCatalogResponse({ generation: 3, groups: [{ id: "stale" }], requestToken: catalogFirst }));
|
|
379
|
+
record("catalog:older-generation", boundary.acceptCatalogResponse({ generation: 1, groups: [{ id: "older" }] }));
|
|
380
|
+
record("catalog:value-overrides-groups", boundary.acceptCatalogResponse({ generation: 3, value: { groups: [{ id: "explicit" }] } }));
|
|
381
|
+
|
|
382
|
+
const fanoutFirst = boundary.beginRequest("fanout", "root-1");
|
|
383
|
+
record("fanout:first", boundary.acceptFanoutSnapshot({ sessionId: "root-1", generation: 1, cursor: 5, snapshot: { tasks: ["new"] }, requestToken: fanoutFirst }));
|
|
384
|
+
const fanoutEvents = boundary.beginRequest("fanout", "root-1");
|
|
385
|
+
record("fanout:events-newer", boundary.acceptFanoutEvents({ sessionId: "root-1", generation: 2, cursor: 6, events: ["event"], requestToken: fanoutEvents }));
|
|
386
|
+
const fanoutStale = boundary.beginRequest("fanout", "root-1");
|
|
387
|
+
record("fanout:older-cursor", boundary.acceptFanoutSnapshot({ sessionId: "root-1", generation: 2, cursor: 4, snapshot: { tasks: ["old"] }, requestToken: fanoutStale }));
|
|
388
|
+
const fanoutInvalid = boundary.beginRequest("fanout", "root-1");
|
|
389
|
+
record("fanout:invalid-generation", boundary.acceptFanoutSnapshot({ sessionId: "root-1", generation: -1, cursor: 7, requestToken: fanoutInvalid }));
|
|
390
|
+
record("fanout:action-fresh", boundary.acceptFanoutAction({ sessionId: "root-1", generation: 2, requestToken: boundary.beginRequest("fanout-action", "root-1") }));
|
|
391
|
+
record("fanout:action-stale-generation", boundary.acceptFanoutAction({ sessionId: "root-1", generation: 1, requestToken: boundary.beginRequest("fanout-action", "root-1") }));
|
|
392
|
+
record("fanout:action-stale-session", boundary.acceptFanoutAction({ sessionId: "root-2", generation: 2, requestToken: boundary.beginRequest("fanout-action", "root-2") }));
|
|
393
|
+
|
|
394
|
+
// session 切换:旧 session 的迟到响应一律拒绝,且新 session 的游标从零重新开始。
|
|
395
|
+
const lateToken = boundary.beginRequest("fanout", "root-1");
|
|
396
|
+
record("session:switch", boundary.beginSession("root-2"));
|
|
397
|
+
record("session:late-old-session", boundary.acceptFanoutSnapshot({ sessionId: "root-1", generation: 9, cursor: 99, snapshot: { tasks: ["late"] }, requestToken: lateToken }));
|
|
398
|
+
record("session:is-session-current", [boundary.isSessionCurrent(lateToken), boundary.isCurrent(lateToken)]);
|
|
399
|
+
record("session:new-session-fresh", boundary.acceptFanoutSnapshot({ sessionId: "root-2", generation: 0, cursor: 0, snapshot: { tasks: [] }, requestToken: boundary.beginRequest("fanout", "root-2") }));
|
|
400
|
+
record("session:snapshot", boundary.snapshot());
|
|
401
|
+
|
|
402
|
+
// dispose:所有迟到响应一律拒绝,且不再通知订阅者。
|
|
403
|
+
record("dispose", boundary.dispose());
|
|
404
|
+
record("dispose:again", boundary.dispose());
|
|
405
|
+
const disposedToken = boundary.beginRequest("settings", "global");
|
|
406
|
+
record("disposed:begin-request", disposedToken);
|
|
407
|
+
record("disposed:accept", boundary.acceptSettingsSnapshot({ revision: 9, value: { provider: "late" }, requestToken: disposedToken }));
|
|
408
|
+
record("disposed:accept-without-token", boundary.acceptSettingsSnapshot({ revision: 9, value: { provider: "late" } }));
|
|
409
|
+
record("disposed:snapshot", boundary.snapshot());
|
|
410
|
+
record("boundary:updates", updates);
|
|
411
|
+
|
|
412
|
+
// ── 5. settings 提交响应解析(冲突 revision 兼容三种回传位置)───────────────
|
|
413
|
+
const commitResponses = {
|
|
414
|
+
accepted: { ok: true, status: 200, async json() { return { ok: true, settings: { provider: "p" }, revision: 8 }; } },
|
|
415
|
+
acceptedArraySettings: { ok: true, status: 200, async json() { return { ok: true, settings: [], revision: 8 }; } },
|
|
416
|
+
acceptedMissingRevision: { ok: true, status: 200, async json() { return { ok: true, settings: { provider: "p" } }; } },
|
|
417
|
+
conflictDetailsRevision: { ok: false, status: 409, async json() { return { ok: false, error: { code: "settings-conflict", message: "冲突", details: { revision: 4 } } }; } },
|
|
418
|
+
conflictCurrentRevision: { ok: false, status: 409, async json() { return { ok: false, error: { code: "settings-conflict", message: "冲突", details: { currentRevision: 5 } } }; } },
|
|
419
|
+
conflictPayloadRevision: { ok: false, status: 409, async json() { return { ok: false, error: { code: "settings-conflict", message: "冲突" }, revision: 6 }; } },
|
|
420
|
+
conflictBare: { ok: false, status: 409, async json() { return { ok: false, error: { code: "settings-conflict", message: "冲突" } }; } },
|
|
421
|
+
conflictTopLevelMessage: { ok: false, status: 500, async json() { return { ok: false, message: "保存失败" }; } },
|
|
422
|
+
invalidJson: { ok: false, status: 500, async json() { throw new Error("响应不是 JSON"); } },
|
|
423
|
+
notOkWithoutError: { ok: false, status: 500, async json() { return { ok: true, settings: { provider: "p" }, revision: 8 }; } },
|
|
424
|
+
};
|
|
425
|
+
for (const [name, response] of Object.entries(commitResponses)) {
|
|
426
|
+
await recordRun("commit-outcome:" + name, async () => await face.readSettingsCommitOutcome(response, identityDraft, ["fixer"]));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return log;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* 比较两份一致性日志,返回逐项差异;空数组表示零漂移。
|
|
434
|
+
* 长度或标签不一致同样视为漂移(脚本步骤被增删也是行为契约变化)。
|
|
435
|
+
*/
|
|
436
|
+
export function diffFreshnessLogs(left, right) {
|
|
437
|
+
const differences = [];
|
|
438
|
+
const length = Math.max(left.length, right.length);
|
|
439
|
+
for (let index = 0; index < length; index += 1) {
|
|
440
|
+
const leftEntry = left[index];
|
|
441
|
+
const rightEntry = right[index];
|
|
442
|
+
if (leftEntry === undefined || rightEntry === undefined) {
|
|
443
|
+
differences.push({ label: (leftEntry ?? rightEntry)?.label ?? "#" + index, left: leftEntry ?? null, right: rightEntry ?? null });
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (leftEntry.label !== rightEntry.label
|
|
447
|
+
|| JSON.stringify(leftEntry.value) !== JSON.stringify(rightEntry.value)) {
|
|
448
|
+
differences.push({ label: leftEntry.label, left: leftEntry.value, right: rightEntry.value });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return differences;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* 构建期投影行为核对:把刚生成的 classic 投影片段放进最小 global 替身里真实执行一次,
|
|
456
|
+
* 再用一致性脚本与 ESM 权威实现逐项比对。
|
|
457
|
+
*
|
|
458
|
+
* 核对三件事,任何一件不成立都让 `pnpm build` 直接失败:
|
|
459
|
+
* 1. 投影尾部真的把导出面落到 `globalThis[Symbol.for(CLIENT_FRESHNESS_GLOBAL_KEY)]`;
|
|
460
|
+
* 2. 投影尾部真的通过 `__ModuleLoader__.load` 登记了契约 id(替身加载器捕获回执);
|
|
461
|
+
* 3. 检查点取到的实现面与登记 descriptor 的 factory 是同一份导出,且与权威实现零漂移。
|
|
462
|
+
*
|
|
463
|
+
* @param {{ segment: string, freshnessModule?: object }} options - segment 是
|
|
464
|
+
* renderClientFreshnessSegment 的产物;freshnessModule 仅供测试注入替身。
|
|
465
|
+
* @returns {Promise<{ steps: number, moduleId: string }>}
|
|
466
|
+
*/
|
|
467
|
+
export async function verifyFreshnessProjection({ segment, freshnessModule } = {}) {
|
|
468
|
+
if (typeof segment !== "string" || segment.length === 0) throw new Error("缺少待核对的 classic 投影片段");
|
|
469
|
+
const authority = freshnessModule ?? await import("../client-freshness.js");
|
|
470
|
+
const moduleId = authority.CLIENT_FRESHNESS_MODULE_ID;
|
|
471
|
+
// 门禁里的 client.js 字面量与权威实现的导出必须一致,否则 require 契约已经断了。
|
|
472
|
+
if (moduleId !== FRESHNESS_MODULE_ID) {
|
|
473
|
+
throw new Error("client-freshness.js 的模块 id 与装配契约不一致: " + JSON.stringify(moduleId));
|
|
474
|
+
}
|
|
475
|
+
const projected = evaluateFreshnessSegment(segment, { globalKey: authority.CLIENT_FRESHNESS_GLOBAL_KEY, moduleId });
|
|
476
|
+
const authorityLog = await probeFreshnessFace(authority);
|
|
477
|
+
const projectedLog = await probeFreshnessFace(projected.face);
|
|
478
|
+
const differences = diffFreshnessLogs(authorityLog, projectedLog);
|
|
479
|
+
if (differences.length > 0) {
|
|
480
|
+
const detail = differences.slice(0, 5).map((diff) => {
|
|
481
|
+
return "- " + diff.label + "\n 权威: " + JSON.stringify(diff.left) + "\n 投影: " + JSON.stringify(diff.right);
|
|
482
|
+
}).join("\n");
|
|
483
|
+
throw new Error("freshness classic 投影与权威实现已漂移(共 " + differences.length + " 项):\n" + detail);
|
|
484
|
+
}
|
|
485
|
+
return Object.freeze({ steps: authorityLog.length, moduleId });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* 在最小 global 替身里执行投影片段,返回检查点导出面与登记回执。
|
|
490
|
+
*
|
|
491
|
+
* 用参数遮蔽 `globalThis`:片段收尾是 `})(globalThis)`,被遮蔽后检查点赋值与
|
|
492
|
+
* client-freshness.js 的"已登记加载器"记账都落在替身上,既不污染构建进程的全局,
|
|
493
|
+
* 也不影响真实装载。替身加载器只实现 DSH 的 `load({ id, factory })` 契约。
|
|
494
|
+
*
|
|
495
|
+
* @param {string} segment - classic 投影片段文本。
|
|
496
|
+
* @param {{ globalKey: string, moduleId: string }} contract - 检查点 Symbol 键与期望登记 id。
|
|
497
|
+
*/
|
|
498
|
+
function evaluateFreshnessSegment(segment, { globalKey, moduleId }) {
|
|
499
|
+
if (typeof globalKey !== "string" || globalKey.length === 0) {
|
|
500
|
+
throw new Error("权威实现未导出 CLIENT_FRESHNESS_GLOBAL_KEY,无法定位投影检查点");
|
|
501
|
+
}
|
|
502
|
+
const registrations = [];
|
|
503
|
+
const sandboxGlobal = {
|
|
504
|
+
__ModuleLoader__: { load: (registration) => { registrations.push(registration); } },
|
|
505
|
+
};
|
|
506
|
+
// 只执行刚生成的片段本身;返回替身以便在宿主 realm 里读取 Symbol 检查点。
|
|
507
|
+
const run = new Function("globalThis", segment + "\nreturn globalThis;");
|
|
508
|
+
const executed = run(sandboxGlobal);
|
|
509
|
+
const face = executed[Symbol.for(globalKey)];
|
|
510
|
+
if (face === null || typeof face !== "object") {
|
|
511
|
+
throw new Error("投影片段未落地 freshness 检查点(globalThis Symbol 上没有导出面)");
|
|
512
|
+
}
|
|
513
|
+
if (registrations.length !== 1 || registrations[0]?.id !== moduleId) {
|
|
514
|
+
throw new Error("投影片段的 ModuleLoader 登记尾部未按契约执行(期望 " + moduleId
|
|
515
|
+
+ ",实际 " + JSON.stringify(registrations.map((registration) => registration?.id)) + ")");
|
|
516
|
+
}
|
|
517
|
+
// 登记出去的就是 client.js 的 require 契约:descriptor.factory() 必须给出同一份导出面。
|
|
518
|
+
const registeredFace = registrations[0].factory();
|
|
519
|
+
if (Object.isFrozen(registeredFace) !== true || registeredFace.createClientFreshnessBoundary !== face.createClientFreshnessBoundary) {
|
|
520
|
+
throw new Error("投影片段的检查点与登记 descriptor 不是同一份导出面");
|
|
521
|
+
}
|
|
522
|
+
return { face, descriptor: registrations[0] };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* 发布 bundle 的装载契约门禁:投影位置、登记语句、client.js 的转发 seam,以及
|
|
527
|
+
* "descriptor 段里不得再有第二份 freshness 实现"。
|
|
528
|
+
* seam 一旦变化(不再 require 该模块、或又写回内联实现),行为核对与客户端测试都必须同步,
|
|
529
|
+
* 因此这里让构建直接失败,而不是悄悄退化成"两份实现各自漂移"。
|
|
530
|
+
*/
|
|
531
|
+
function verifyClientBundleContract(content) {
|
|
532
|
+
const freshnessIndex = content.indexOf("/* source: " + FRESHNESS_SOURCE + " */");
|
|
533
|
+
const descriptorIndex = content.indexOf("/* source: " + DESCRIPTOR_SOURCE + " */");
|
|
534
|
+
if (freshnessIndex === -1) throw new Error("发布 bundle 缺少 " + FRESHNESS_SOURCE + " 的 classic 投影段");
|
|
535
|
+
if (descriptorIndex === -1) throw new Error("发布 bundle 缺少 " + DESCRIPTOR_SOURCE + " 的 descriptor 段");
|
|
536
|
+
if (freshnessIndex > descriptorIndex) throw new Error("freshness 投影必须在唯一 ModuleLoader descriptor 之前执行");
|
|
537
|
+
for (const statement of [CHECKPOINT_STATEMENT, REGISTER_STATEMENT]) {
|
|
538
|
+
if (!content.includes(statement)) throw new Error("freshness 投影缺少装载语句: " + statement);
|
|
539
|
+
}
|
|
540
|
+
const descriptorMarkers = [
|
|
541
|
+
"const FRESHNESS_MODULE_ID = " + JSON.stringify(FRESHNESS_MODULE_ID) + ";",
|
|
542
|
+
"freshnessModule.registerClientFreshnessModule(globalThis.__ModuleLoader__);",
|
|
543
|
+
"const RPC_CHANNEL = " + JSON.stringify(RPC_TARGET.channel) + ";",
|
|
544
|
+
...DESCRIPTOR_SEAM_MARKERS,
|
|
545
|
+
];
|
|
546
|
+
const descriptorContent = content.slice(descriptorIndex);
|
|
547
|
+
for (const marker of descriptorMarkers) {
|
|
548
|
+
if (!descriptorContent.includes(marker)) {
|
|
549
|
+
throw new Error("client.js 的 freshness 装载 seam 已变化,请同步投影与行为核对: " + marker);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
for (const declaration of FORBIDDEN_INLINE_DECLARATIONS) {
|
|
553
|
+
if (descriptorContent.includes(declaration)) {
|
|
554
|
+
throw new Error("client.js 又出现了 freshness 内联实现 " + declaration
|
|
555
|
+
+ "(唯一实现在 client-freshness.js,客户端只许转发;双份实现会让安装体积翻倍并重新引入漂移)");
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** classic script 语法门禁:浏览器按 Script 目标解析发布 bundle,残留 ESM 语法必须在这里失败。 */
|
|
561
|
+
function verifyClassicScript(content) {
|
|
562
|
+
try {
|
|
563
|
+
// 只编译不执行:整段内容是"注册 + 工厂定义",编译即可暴露解析期问题。
|
|
564
|
+
new Function(content);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
throw new Error("发布 bundle 不是合法的 classic script(疑似残留 ESM 语法): " + error.message);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* 确定性装配发布 bundle。
|
|
572
|
+
*
|
|
573
|
+
* @param {{ write?: boolean }} [options] - write=false 时只计算结果(测试与一致性校验使用)。
|
|
574
|
+
*/
|
|
575
|
+
export async function assembleClientBundle({ write = true } = {}) {
|
|
576
|
+
const sources = await Promise.all(sourceNames.map(async (name) => ({
|
|
577
|
+
name,
|
|
578
|
+
content: await readFile(join(root, name), "utf8"),
|
|
579
|
+
})));
|
|
580
|
+
const segments = sources.map(({ name, content }) => {
|
|
581
|
+
if (name === FRESHNESS_SOURCE) return renderClientFreshnessSegment({ name, source: content });
|
|
582
|
+
return "/* source: " + name + " */\n" + content.trimEnd() + "\n";
|
|
583
|
+
});
|
|
584
|
+
const content = banner + segments.join("\n");
|
|
585
|
+
verifyClientBundleContract(content);
|
|
586
|
+
verifyClassicScript(content);
|
|
587
|
+
// 行为核对直接用刚生成的那一段(而不是重新渲染),确保校验对象就是要写盘的内容。
|
|
588
|
+
const freshnessSegment = segments[sources.findIndex(({ name }) => name === FRESHNESS_SOURCE)];
|
|
589
|
+
const projection = await verifyFreshnessProjection({ segment: freshnessSegment });
|
|
590
|
+
const outputPath = join(root, outputName);
|
|
591
|
+
if (write) await writeFile(outputPath, content, "utf8");
|
|
592
|
+
return Object.freeze({
|
|
593
|
+
outputPath,
|
|
594
|
+
sourceNames,
|
|
595
|
+
content,
|
|
596
|
+
// 行为核对步数是"投影与权威实现逐项一致"的证据,构建日志与测试都据此断言。
|
|
597
|
+
freshnessProbeSteps: projection.steps,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) {
|
|
602
|
+
const result = await assembleClientBundle();
|
|
603
|
+
console.log("Generated " + result.outputPath + " from " + result.sourceNames.length + " sources; freshness 投影行为核对 "
|
|
604
|
+
+ result.freshnessProbeSteps + " 步零漂移。");
|
|
605
|
+
}
|