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.
Files changed (45) hide show
  1. package/LICENSE +38 -0
  2. package/README.md +355 -0
  3. package/THIRD_PARTY_NOTICES.md +35 -0
  4. package/assets/notification-whoosh.wav +0 -0
  5. package/bin/prompt-inspector.mjs +63 -0
  6. package/client-bundle.js +14891 -0
  7. package/client-expert-group-model.js +426 -0
  8. package/client-expert-group-sidebar.js +5401 -0
  9. package/client-freshness.js +364 -0
  10. package/client-model-config.js +2155 -0
  11. package/client-notifications.js +38 -0
  12. package/client.js +6487 -0
  13. package/cordis.patch.yml +10 -0
  14. package/fanout-host.js +1507 -0
  15. package/fanout.js +2981 -0
  16. package/host/composition.js +155 -0
  17. package/host/expert-group-runtime.js +822 -0
  18. package/host/expert-group-snapshot.js +450 -0
  19. package/host/fanout-runtime.js +822 -0
  20. package/host/handoff.js +80 -0
  21. package/host/lineage.js +274 -0
  22. package/host/notifications.js +189 -0
  23. package/host/persistence-health.js +389 -0
  24. package/host/recovery.js +242 -0
  25. package/host/rpc-transport.js +573 -0
  26. package/host/settings-runtime.js +168 -0
  27. package/host/settings-source.js +265 -0
  28. package/host/storage-binding.js +94 -0
  29. package/host/system-toast.js +916 -0
  30. package/image-routing.js +263 -0
  31. package/index.js +9327 -0
  32. package/install.ps1 +219 -0
  33. package/orchestrator.js +239 -0
  34. package/package.json +142 -0
  35. package/prompt-contracts.js +148 -0
  36. package/prompt-inspector.js +584 -0
  37. package/prompt-sidecar.js +610 -0
  38. package/resource-budget.js +540 -0
  39. package/role-registry.js +286 -0
  40. package/rpc-host.js +310 -0
  41. package/scripts/assemble-client.mjs +605 -0
  42. package/scripts/build.mjs +124 -0
  43. package/shared.js +4003 -0
  44. package/storage.js +190 -0
  45. package/uninstall.ps1 +47 -0
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Host 关注点模块:settings 运行时状态(DSH 0.1.7-alpha.2 契约)。
3
+ *
4
+ * 持有三件事,全部服务于 **DEG 自有提交端点**(index.js 的 commitSettingsCandidate):
5
+ * - 模型目录 generation 计数与 settings document revision 去重(两参数事件规则);
6
+ * - 全局 settings 提交串行链(不等待可能永久 pending 的 domain 写入);
7
+ * - 写入前的最后一道代次复核:提交起始代次与当前代次不一致时拒绝写入。
8
+ *
9
+ * 保证边界(规格书已记录的降级):
10
+ * DEG **不承诺**在宿主通用 settings 写入路径上也保证模型目录有效。宿主没有 validate
11
+ * 钩子,配置编辑是同步的,DEG 的锁无法协调由通用写入发起的 provider 配置改动;因此
12
+ * 目录有效性只覆盖「DEG 自有提交端点在自己的写入前可检测到的变化」。
13
+ *
14
+ * 运行数据仍属于 storage domain;本模块只处理 settings 提交边界。
15
+ *
16
+ * @module dsh-expert-group/host/settings-runtime
17
+ */
18
+
19
+ /** 写入前发现模型目录代次变化;RPC 层按 code 映射为可重试的保存失败。 */
20
+ export class SettingsDirectoryChangedError extends Error {
21
+ constructor(message = "模型目录在保存期间发生变化,请刷新目录后重试") {
22
+ super(message);
23
+ this.name = "SettingsDirectoryChangedError";
24
+ this.code = "MODEL_DIRECTORY_CHANGED";
25
+ }
26
+ }
27
+
28
+ /**
29
+ * 把一层提交 patch 转成宿主的路径寻址写入操作。
30
+ *
31
+ * 每个顶层字段一律用「整值 set」表达,**绝不**用宿主 `update` 的递归合并:
32
+ * DEG 的 defaults / promptOverrides 是整份映射语义(空对象必须清空全部覆盖、
33
+ * 提交的 defaults 必须整体替换而不是与旧值做键级并集)。宿主的
34
+ * `mutate` + `{op:'set', path:[key]}` 在路径末段整体赋值,等价于旧契约里
35
+ * `update` 在顶层键上的行为;同时宿主会校验该路径确实是 volatile 字段。
36
+ *
37
+ * patch 值必须是 JSON 可表达的数据;`undefined` 条目由调用方负责过滤,不会出现在这里。
38
+ *
39
+ * @param {Record<string, unknown>} patch - 已归一的提交 patch。
40
+ * @returns {Array<{op:"set", path:string[], value:unknown}>} 有序写入操作。
41
+ */
42
+ function settingsWriteOps(patch) {
43
+ return Object.entries(patch ?? {}).map(([key, value]) => ({ op: "set", path: [key], value }));
44
+ }
45
+
46
+ /**
47
+ * @param {object} options
48
+ * @param {object} options.settings - 注入的 ctx.settings(需要 mutate)。
49
+ * @param {string} options.namespace - 插件 settings namespace(= profile 条目 id)。
50
+ */
51
+ export function createSettingsRuntimeModule({ settings, namespace } = {}) {
52
+ if (settings === null || typeof settings !== "object" || typeof settings.mutate !== "function") {
53
+ throw new TypeError("createSettingsRuntimeModule 需要 settings 服务(含 mutate)");
54
+ }
55
+ if (typeof namespace !== "string" || namespace.length === 0) {
56
+ throw new TypeError("createSettingsRuntimeModule 需要 namespace");
57
+ }
58
+ let generation = 0;
59
+ let writeChain = Promise.resolve();
60
+ let pendingWrites = 0;
61
+ let disposed = false;
62
+ const documentRevisions = new Map();
63
+
64
+ return Object.freeze({
65
+ get generation() { return generation; },
66
+ /** 目录/凭据变化推进代际,使进行中的提交过期。 */
67
+ bumpGeneration() {
68
+ generation += 1;
69
+ return generation;
70
+ },
71
+ isCurrentGeneration(value) {
72
+ return value === generation;
73
+ },
74
+ /**
75
+ * settings document 事件:按 namespace + revision 去重。
76
+ *
77
+ * DSH 0.1.7-alpha.2 只发送 `(ns, revision)` 两个参数(注册世代事件与
78
+ * registration token 都已从宿主删除),因此去重基线只有 revision:
79
+ * - 缺失/非法 revision:清除基线并保守失效(无法安全去重);
80
+ * - 同 namespace 同 revision:忽略(同一份文档,没有新变化);
81
+ * - 更高 revision:记录新基线并推进代际;
82
+ * - 更低 revision:只可能来自被替换的 settings 服务(新实例从 0 计数),
83
+ * 因此清除基线并保守失效,绝不把“新世代的低 revision”当成旧文档忽略。
84
+ * 去重基线在失效后会被清空,下一次同 revision 事件会重新失效——宁可多推进一次
85
+ * 代际,也不能因为 revision 回绕而错过一次目录变化。
86
+ * @returns {"ignored"|"invalidated"}
87
+ */
88
+ noteDocumentUpdate(namespaceName, revision) {
89
+ if (typeof namespaceName !== "string" || namespaceName.length === 0 || namespaceName === namespace) return "ignored";
90
+ if (!Number.isSafeInteger(revision) || revision < 0) {
91
+ documentRevisions.delete(namespaceName);
92
+ generation += 1;
93
+ return "invalidated";
94
+ }
95
+ const previous = documentRevisions.get(namespaceName);
96
+ if (previous !== undefined && revision === previous.revision) return "ignored";
97
+ documentRevisions.set(namespaceName, { revision });
98
+ generation += 1;
99
+ return "invalidated";
100
+ },
101
+ /**
102
+ * 全局 settings 提交串行链:失败不阻断后续提交,且不等待任何 domain 写入。
103
+ *
104
+ * 串行化是 revision 冲突语义的前提:并发提交按调用顺序排队,后一条看到前一条
105
+ * 落盘后的 revision,因此携带陈旧 expectedRevision 的提交会被宿主明确拒绝,而
106
+ * 不是互相覆盖。
107
+ * @param {Function} operation
108
+ */
109
+ enqueueCommit(operation) {
110
+ if (typeof operation !== "function") throw new TypeError("enqueueCommit 需要函数");
111
+ if (disposed) {
112
+ const error = new Error("settings runtime 已关闭");
113
+ error.code = "SETTINGS_RUNTIME_DISPOSED";
114
+ return Promise.reject(error);
115
+ }
116
+ pendingWrites += 1;
117
+ const run = writeChain.catch(() => {}).then(operation);
118
+ writeChain = run.catch(() => {});
119
+ let settled = false;
120
+ const settle = () => {
121
+ if (settled) return;
122
+ settled = true;
123
+ pendingWrites = Math.max(0, pendingWrites - 1);
124
+ };
125
+ run.then(settle, settle);
126
+ return run;
127
+ },
128
+ /**
129
+ * 在目录代次栅栏内写入 settings(DEG 自有提交端点的实际写入边界)。
130
+ *
131
+ * 调用方(commitSettingsCandidate)在串行链内先做语义校验与目录解析,再调本方法;
132
+ * 本方法在真正调用宿主写入**之前**复核代次:`generationAtStart` 与当前代次不一致
133
+ * 说明校验期间模型目录已变化,提交必须过期(抛 SettingsDirectoryChangedError)。
134
+ *
135
+ * 这是「提交前可检测的变化」这一降级保证的落点:它只覆盖 DEG 自己这条路径,
136
+ * 且只覆盖到宿主写入调用之前;不承诺拦截宿主通用写入路径,也不承诺封住宿主
137
+ * 异步落盘期间的竞态。
138
+ *
139
+ * 写入原语是 `mutate` + 顶层 `set`,而不是 `update`(理由见 settingsWriteOps)。
140
+ */
141
+ async updateAtGeneration(patch, expectedRevision, generationAtStart) {
142
+ if (disposed) {
143
+ const error = new Error("settings runtime 已关闭");
144
+ error.code = "SETTINGS_RUNTIME_DISPOSED";
145
+ throw error;
146
+ }
147
+ // 写入前的最后一道代次栅栏:校验完成到写入之间目录变化则不写。
148
+ if (generationAtStart !== generation) throw new SettingsDirectoryChangedError();
149
+ return settings.mutate(namespace, settingsWriteOps(patch), expectedRevision);
150
+ },
151
+ describe: () => ({
152
+ generation,
153
+ trackedNamespaces: documentRevisions.size,
154
+ pendingWrites,
155
+ }),
156
+ /**
157
+ * 幂等清理:监听器由 ctx 生命周期释放,本模块清空内部状态。
158
+ * 已登记的提交继续沿原串行链 drain;不重置 writeChain 或 pendingWrites,
159
+ * 由各自的幂等 settle 在迟到完成后准确收敛计数。
160
+ */
161
+ dispose() {
162
+ if (disposed) return;
163
+ disposed = true;
164
+ generation = 0;
165
+ documentRevisions.clear();
166
+ },
167
+ });
168
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Host 关注点模块:settings 源绑定(DSH 0.1.7-alpha.2 契约)。
3
+ *
4
+ * 接线方式:
5
+ * - 引导期通过 `ctx.settings.configure({ auto: false }, ctx.fiber)` 关闭宿主的自动
6
+ * 生成配置页(DEG 有自己的设置页)。owner 必须是**插件自己的 fiber**:宿主按
7
+ * fiber 记录页面策略,省略 owner 会把策略记到 settings 服务自身并立刻因重复注册
8
+ * 抛错。返回的 disposer 挂到插件 effect 上,随生命周期释放。
9
+ * - 本条目当前值从 **describe()** 读取(找 ns === 本条目 id 的项取 value),不再使用
10
+ * 已删除的 `settings.register` / `scope.get()` / `scope.watch()`。
11
+ * - 运行期变更观察有三个观察点,全部收敛到同一个 `observeConfiguration()` 入口:
12
+ * 1. `loader/volatile-update`(**主**):loader 把 volatile-only 变更提交进运行中的
13
+ * fiber 之后派发;这是设置页保存与外部编辑生效最及时的信号。
14
+ * 2. `app-boot/config-reload`(**兜底**):profile patch 归并完成、生命周期 settle
15
+ * 后无条件派发,覆盖只走重载路径的写入(程序化更新、HMR 关闭时的外部编辑)。
16
+ * 3. `settings/document-updated(ns, revision)`:本条目文档自身变化,保留兼容。
17
+ *
18
+ * 观察点注册位置是**契约要求**,不是风格选择:
19
+ * `loader/volatile-update` 由 loader 以 `fiber.ctx.emit(self, event, paths)` 派发,
20
+ * 其中 `self[Context.filter] = (owner) => owner.fiber === <条目 fiber>`。只有注册在
21
+ * 插件**顶层 ctx**(其 `hook.ctx.fiber` 正是条目 fiber)上的监听器能收到;
22
+ * `ctx.inject` 回调拿到的子 ctx 拥有**独立 fiber**,注册在那里永远收不到该事件。
23
+ * 因此三个观察点都注册在注入回调**之外**的顶层 ctx 上:既满足 fiber 过滤,也保证
24
+ * settings 服务替换时不会累积重复监听器。
25
+ *
26
+ * 严格边界:
27
+ * - 宿主新契约**没有** validate 钩子(`settings.register` 已删除),因此本模块不
28
+ * 接收、不安装任何 validate;语义校验由 DEG 自有提交端点承担
29
+ * (见 index.js 的 commitSettingsCandidate)。
30
+ * - describe() 返回的 value 是 defaults resolved 后的结果,与旧 `scope.get()` 的
31
+ * “已注册 schema 解析值”语义一致:读取归一仍按缺失字段回退默认值处理。
32
+ *
33
+ * @module dsh-expert-group/host/settings-source
34
+ */
35
+
36
+ /**
37
+ * 运行期配置观察事件(按优先级排列)。
38
+ *
39
+ * - `loader/volatile-update`:条目本地事件,**带 fiber 过滤** → 必须注册在插件顶层 ctx。
40
+ * - `app-boot/config-reload`:无过滤的全局事件,作为兜底。
41
+ */
42
+ const CONFIG_OBSERVATION_EVENTS = Object.freeze([
43
+ "loader/volatile-update",
44
+ "app-boot/config-reload",
45
+ ]);
46
+
47
+ /**
48
+ * 从 describe() 结果里读取本条目描述符。
49
+ *
50
+ * 宿主只会把「声明了 volatile 字段的条目」放进 describe();条目 id 被用户重命名
51
+ * 时这里读不到,调用方必须回落到插件默认 entry(不崩溃、不伪造值)。describe()
52
+ * 在配置编辑器尚未就绪等边界上可能抛错:读取失败只代表“当前不可读”。
53
+ *
54
+ * @param {object} service - 注入的 ctx.settings。
55
+ * @param {string} namespace - profile 条目 id(本插件为 dsh-expert-group)。
56
+ * @returns {object|undefined} 该条目的描述符;不可读时 undefined。
57
+ */
58
+ export function findSettingsDescriptor(service, namespace) {
59
+ if (service === null || typeof service?.describe !== "function") return undefined;
60
+ let descriptors;
61
+ try {
62
+ descriptors = service.describe();
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ if (!Array.isArray(descriptors)) return undefined;
67
+ const descriptor = descriptors.find((candidate) => candidate?.ns === namespace);
68
+ if (descriptor === undefined) return undefined;
69
+ if (descriptor.value === null || typeof descriptor.value !== "object") return undefined;
70
+ return descriptor;
71
+ }
72
+
73
+ /**
74
+ * 只取值面的便捷读取:找不到或 value 非对象时返回 undefined。
75
+ *
76
+ * @param {object} service - 注入的 ctx.settings。
77
+ * @param {string} namespace - profile 条目 id。
78
+ * @returns {object|undefined} 该条目的当前值(defaults resolved)。
79
+ */
80
+ export function readSettingsDescriptorValue(service, namespace) {
81
+ return findSettingsDescriptor(service, namespace)?.value;
82
+ }
83
+
84
+ /**
85
+ * @param {object} options
86
+ * @param {number[]} [options.unloadingStates] - 覆盖卸载态数值(测试用)。
87
+ */
88
+ export function createSettingsSourceModule({ unloadingStates = [4, 5] } = {}) {
89
+ const states = Array.isArray(unloadingStates) ? unloadingStates.slice() : [4, 5];
90
+ /**
91
+ * 当前绑定与观察状态;`dispose()` 置空。
92
+ *
93
+ * 单一对象同时承载「读源绑定」与「观察计数」,避免出现半释放的不一致:只要它为
94
+ * null,所有迟到事件与读取都变成 no-op。
95
+ */
96
+ let state = null;
97
+
98
+ function isUnloading(ctx) {
99
+ try {
100
+ const stateValue = ctx?.fiber?.state;
101
+ return states.includes(stateValue);
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 运行期统一观察入口:**先重读**宿主 describe(),再按 revision **幂等**通知。
109
+ *
110
+ * - **重读总是发生**:读取面在事件到达后立刻看到宿主权威值,不存在旧快照缓存,
111
+ * 因此「设置保存后运行期立即跟随」不依赖任何记忆化假设。
112
+ * - **通知按 revision 去重**:一次用户保存通常先后触发 `loader/volatile-update`、
113
+ * `app-boot/config-reload`,有时还叠加 `settings/document-updated`,它们描述的是
114
+ * 同一次变更;去重后 hooks.onChange() 只触发一次,不放大成刷新风暴。
115
+ * - revision 不可用时**不去重**:宁可多通知一次,也不能因为宿主/替身不提供版本号
116
+ * 而漏掉真实变更。
117
+ * - 未安装、已释放、卸载中、服务未就绪:不重读也不通知。
118
+ *
119
+ * @returns {boolean} 本次是否真的通知了变更。
120
+ */
121
+ function observeConfiguration() {
122
+ const active = state;
123
+ if (active === null || active.mode !== "inject") return false;
124
+ if (isUnloading(active.ctx)) return false;
125
+ if (active.refresh === null) return false;
126
+ // 重入保护:真实宿主的 describe() 自身会在 revision 变化时派发
127
+ // `settings/document-updated`,因此「本条目文档事件 → 重读 describe()」可能递归回到
128
+ // 本函数。重入的那一层直接返回,让最外层完成读取与通知,避免递归放大。
129
+ if (active.observing) return false;
130
+ active.observing = true;
131
+ let descriptor;
132
+ try {
133
+ descriptor = active.refresh();
134
+ } finally {
135
+ active.observing = false;
136
+ }
137
+ const revision = descriptor === undefined ? undefined : descriptor.revision;
138
+ if (revision !== undefined && active.known && revision === active.revision) {
139
+ active.suppressed += 1;
140
+ return false;
141
+ }
142
+ active.known = revision !== undefined;
143
+ active.revision = revision;
144
+ active.notifications += 1;
145
+ active.hooks.onChange();
146
+ return true;
147
+ }
148
+
149
+ return Object.freeze({
150
+ /**
151
+ * 按 0.1.7-alpha.2 契约接线:只通过注入的 ctx.settings 交互。
152
+ * @param {object} ctx - 插件 ctx(**顶层**;观察点必须注册在它上面)。
153
+ * @param {object} options
154
+ * @param {string} options.namespace - profile 条目 id。
155
+ * @param {object} [options.schema] - 旧签名的 schema 参数;宿主现在自行从条目
156
+ * Config 投影,这里只保留参数形状,不再参与注册。
157
+ * @param {object} options.entry - describe() 不可读时的默认值(插件默认配置)。
158
+ * @param {{setSource:Function, onChange:Function}} options.hooks
159
+ */
160
+ install(ctx, { namespace, schema: _schema, entry, hooks } = {}) {
161
+ if (ctx === null || typeof ctx !== "object") throw new TypeError("settings source install 需要 ctx");
162
+ if (typeof namespace !== "string" || namespace.length === 0) throw new TypeError("settings source install 需要 namespace");
163
+ if (typeof hooks?.setSource !== "function" || typeof hooks?.onChange !== "function") {
164
+ throw new TypeError("settings source install 需要 setSource/onChange hooks");
165
+ }
166
+ if (typeof ctx.inject !== "function") {
167
+ // 旧/测试 Host 没有 inject:保持 entry 作为 source,不抛错。
168
+ hooks.setSource(() => entry);
169
+ hooks.onChange();
170
+ state = { namespace, mode: "entry", notifications: 0, suppressed: 0 };
171
+ return { namespace, mode: "entry" };
172
+ }
173
+
174
+ // 观察状态:服务未就绪时 refresh 为 null,事件一律 no-op(读取面仍是 entry)。
175
+ state = {
176
+ namespace,
177
+ mode: "inject",
178
+ ctx,
179
+ hooks,
180
+ refresh: null,
181
+ observing: false,
182
+ known: false,
183
+ revision: undefined,
184
+ notifications: 0,
185
+ suppressed: 0,
186
+ };
187
+
188
+ // 顶层观察点:只注册一次(不在注入回调里,避免服务替换时累积重复监听器)。
189
+ if (typeof ctx.on === "function") {
190
+ for (const event of CONFIG_OBSERVATION_EVENTS) {
191
+ ctx.on(event, () => { observeConfiguration(); });
192
+ }
193
+ // 本条目文档自身变化:与 loader 事件共用同一幂等入口。DEG 自己的提交也会走到
194
+ // 这里,因此读取面在提交完成后立即收敛到宿主权威值(不再依赖 registration token)。
195
+ ctx.on("settings/document-updated", (updatedNamespace) => {
196
+ if (updatedNamespace !== namespace) return;
197
+ observeConfiguration();
198
+ });
199
+ }
200
+
201
+ ctx.inject(["settings"], (settingsCtx) => {
202
+ const service = settingsCtx.settings;
203
+ const active = state;
204
+ if (active === null) return;
205
+ // 最新一次 describe() 读到的本条目值;undefined 表示当前不可读,读取面回落到 entry。
206
+ let current;
207
+
208
+ /** 重新读取宿主 describe();条目消失时把 current 复位成 undefined(回落 entry)。 */
209
+ const refresh = () => {
210
+ const descriptor = findSettingsDescriptor(service, namespace);
211
+ current = descriptor === undefined ? undefined : descriptor.value;
212
+ return descriptor;
213
+ };
214
+ active.refresh = refresh;
215
+ // 服务世代切换:describe() 的 revision 从新实例重新计数,旧去重基线必须作废,
216
+ // 否则新世代的首个变更会被误判成「同一份文档」而漏通知。
217
+ active.known = false;
218
+ active.revision = undefined;
219
+
220
+ const bindEffect = typeof settingsCtx?.effect === "function" ? settingsCtx.effect.bind(settingsCtx) : ctx.effect.bind(ctx);
221
+
222
+ // 关闭宿主自动生成配置页(DEG 有自己的设置页)。owner 必须是插件自己的 fiber,
223
+ // 否则宿主会把策略记到 settings 服务自身并在重复注册时抛错。
224
+ bindEffect(() => {
225
+ if (typeof service?.configure !== "function") return () => {};
226
+ return service.configure({ auto: false }, ctx.fiber);
227
+ }, "dsh-expert-group: settings presentation policy");
228
+
229
+ // 首次读取并建立去重基线:安装期的 hooks.onChange() 自己会通知,不占用观察通道。
230
+ const descriptor = refresh();
231
+ active.known = descriptor !== undefined && Number.isSafeInteger(descriptor.revision);
232
+ active.revision = active.known ? descriptor.revision : undefined;
233
+
234
+ hooks.setSource(() => (current === undefined ? entry : current));
235
+ bindEffect(() => () => {
236
+ if (isUnloading(ctx)) return;
237
+ hooks.setSource(() => entry);
238
+ hooks.onChange();
239
+ }, "dsh-expert-group: settings source binding");
240
+ hooks.onChange();
241
+ });
242
+ return { namespace, mode: "inject" };
243
+ },
244
+ /**
245
+ * 只读装配记录(含运行期观察计数)。
246
+ *
247
+ * `observations` 供回归用例断言幂等与释放语义:
248
+ * - notifications:真正触发 hooks.onChange() 的次数;
249
+ * - suppressed:被 revision 去重抑制的事件次数。
250
+ */
251
+ describe: () => (state === null
252
+ ? { installed: false }
253
+ : {
254
+ installed: true,
255
+ namespace: state.namespace,
256
+ mode: state.mode,
257
+ observations: { notifications: state.notifications, suppressed: state.suppressed },
258
+ }),
259
+ dispose() {
260
+ // 释放后观察通道失效:所有迟到事件(loader 已提交 volatile 值、事件仍在队列里)
261
+ // 都变成 no-op,插件卸载后不得再刷新任何读源。
262
+ state = null;
263
+ },
264
+ });
265
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Host 关注点模块:storage binding / 初始化代际 / 快照缓存。
3
+ *
4
+ * 接管原先散落在 apply 里的 binding 生成器实例、active binding 投影与
5
+ * domain/fan-out 快照缓存;写入串行链与 storage domain 的 open/close 时序仍留在
6
+ * index.js(源码契约测试锁定),任务 06 再收缩。
7
+ *
8
+ * @module dsh-expert-group/host/storage-binding
9
+ */
10
+ import { createBindingController } from "../storage.js";
11
+
12
+ /**
13
+ * @param {object} [options]
14
+ * @param {object} [options.bindingController] - 注入既有控制器(测试用)。
15
+ */
16
+ export function createStorageBindingModule({ bindingController = createBindingController() } = {}) {
17
+ if (bindingController === null || typeof bindingController !== "object"
18
+ || typeof bindingController.begin !== "function" || typeof bindingController.install !== "function") {
19
+ throw new TypeError("createStorageBindingModule 需要 bindingController");
20
+ }
21
+ let domainSnapshot = {};
22
+ let fanoutSnapshot;
23
+
24
+ return Object.freeze({
25
+ /**
26
+ * 开始新 generation:旧 binding 立即失效,且清空快照缓存,
27
+ * 避免新 generation 复用上一代的 domain/fan-out 数据。
28
+ */
29
+ begin() {
30
+ const binding = bindingController.begin();
31
+ domainSnapshot = {};
32
+ fanoutSnapshot = undefined;
33
+ return binding;
34
+ },
35
+ /** 安装 ready binding;迟到/被替换的 binding 返回 false。 */
36
+ install: (binding, value) => bindingController.install(binding, value),
37
+ /**
38
+ * 使 binding 失效:脱离 current、清空快照缓存并取消未完成的初始化。
39
+ * 旧 generation 因此无法再回写新 binding。
40
+ *
41
+ * 快照缓存的重置是无条件的:domainSnapshot 恒为普通对象(只能被 begin()/setDomainSnapshot()
42
+ * 赋成对象或 {}),因此这里不存在「未初始化就跳过」的情形,无需再判断它是否为 undefined。
43
+ */
44
+ invalidate(binding) {
45
+ if (binding === undefined || binding === null) return false;
46
+ bindingController.invalidate(binding);
47
+ domainSnapshot = {};
48
+ try {
49
+ binding.cancelInitialization?.();
50
+ } catch {
51
+ // 取消回调是宿主注入的可选能力;异常不能影响失效本身。
52
+ }
53
+ return true;
54
+ },
55
+ isCurrent: (binding) => bindingController.isCurrent(binding),
56
+ current: () => bindingController.current(),
57
+ /** 只返回 ready binding;opening/fallback 都视为不可用。 */
58
+ active() {
59
+ const binding = bindingController.current();
60
+ return binding?.status === "ready" ? binding : void 0;
61
+ },
62
+ setDomainSnapshot(value) {
63
+ domainSnapshot = value !== null && typeof value === "object" ? value : {};
64
+ return domainSnapshot;
65
+ },
66
+ setFanoutSnapshot(value) {
67
+ fanoutSnapshot = value;
68
+ return fanoutSnapshot;
69
+ },
70
+ domainSnapshot: () => domainSnapshot,
71
+ fanoutSnapshot: () => fanoutSnapshot,
72
+ /** readStorageSnapshot 使用的合并投影(settings 字段由调用方补)。 */
73
+ snapshots: () => ({ ...domainSnapshot }),
74
+ describe() {
75
+ const binding = bindingController.current();
76
+ return {
77
+ hasBinding: binding !== undefined,
78
+ status: binding?.status,
79
+ generation: binding?.generation,
80
+ tableNames: binding === undefined ? [] : ["session_overrides", "child_models", "fanout_tasks"].filter((name) => binding[tableKeyOf(name)] !== undefined),
81
+ };
82
+ },
83
+ dispose() {
84
+ domainSnapshot = {};
85
+ fanoutSnapshot = undefined;
86
+ },
87
+ });
88
+ }
89
+
90
+ function tableKeyOf(tableName) {
91
+ if (tableName === "session_overrides") return "sessionTable";
92
+ if (tableName === "child_models") return "childTable";
93
+ return "fanoutTable";
94
+ }