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,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host 关注点模块:Expert Group JSON 投影。
|
|
3
|
+
*
|
|
4
|
+
* 把「一个根会话 + 它的普通专家子代理 + 它的 fan-out 父任务」这套运行时对象,
|
|
5
|
+
* 归一成一份**只读的 JSON 树**(schemaVersion:1),供右侧栏/客户端直接渲染。
|
|
6
|
+
*
|
|
7
|
+
* 设计边界:
|
|
8
|
+
*
|
|
9
|
+
* 1. 单向投影:本模块只读输入,绝不修改调用方对象(数组一律复制后处理,禁止原地排序)。
|
|
10
|
+
* 2. 不重算状态:fan-out manager 已经给出 task/shard 明确状态时原样采用;只有在
|
|
11
|
+
* manager 没给 shard 状态时才回退到「最新 attempt」的状态。客户端不得再重算。
|
|
12
|
+
* 3. 不猜内容:读不到的字段统一归一为 null,状态词表外的值统一归一为 "unknown";
|
|
13
|
+
* 缺失 taskName/content/contentSource 时不会用 id、prompt 或时间戳去拼凑。
|
|
14
|
+
* 4. 幂等:同一份输入重复投影得到深度相等的输出,便于 RPC 缓存与差异比较。
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-expert-group/host/expert-group-snapshot
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 投影 schema 版本;客户端只接受该版本的负载。 */
|
|
20
|
+
export const EXPERT_GROUP_SNAPSHOT_SCHEMA_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 统一状态词表(Host 投影出口的唯一状态集合)。
|
|
24
|
+
*
|
|
25
|
+
* - queued/waiting:已登记但尚未真正执行;
|
|
26
|
+
* - running:正在执行;
|
|
27
|
+
* - succeeded/failed/cancelled/timed_out/interrupted:终态(interrupted 表示运行时
|
|
28
|
+
* 句柄丢失,只能由重启后的 restore 判定);
|
|
29
|
+
* - idle/ready:DSH 的空闲语义(idle=已加载但当前无任务,ready=仅存在于存储中、可续接);
|
|
30
|
+
* - unknown:状态缺失或词表外的值,投影不猜测。
|
|
31
|
+
*/
|
|
32
|
+
export const EXPERT_GROUP_STATUSES = Object.freeze([
|
|
33
|
+
"queued",
|
|
34
|
+
"waiting",
|
|
35
|
+
"running",
|
|
36
|
+
"succeeded",
|
|
37
|
+
"failed",
|
|
38
|
+
"cancelled",
|
|
39
|
+
"timed_out",
|
|
40
|
+
"interrupted",
|
|
41
|
+
"idle",
|
|
42
|
+
"ready",
|
|
43
|
+
"unknown",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const STATUS_SET = new Set(EXPERT_GROUP_STATUSES);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 同义状态别名表:只收敛**拼写差异**(大小写、连字符、空格、常见同义词),
|
|
50
|
+
* 不做语义推断。例如 "completed" 与 "succeeded" 在 fan-out 与 DSH 中含义一致,
|
|
51
|
+
* 因此可安全归一;而 "paused"/"blocked" 之类没有对应词表项,一律落回 "unknown"。
|
|
52
|
+
*/
|
|
53
|
+
const STATUS_ALIASES = new Map([
|
|
54
|
+
["canceled", "cancelled"],
|
|
55
|
+
["timeout", "timed_out"],
|
|
56
|
+
["timedout", "timed_out"],
|
|
57
|
+
["timed-out", "timed_out"],
|
|
58
|
+
["success", "succeeded"],
|
|
59
|
+
["successful", "succeeded"],
|
|
60
|
+
["complete", "succeeded"],
|
|
61
|
+
["completed", "succeeded"],
|
|
62
|
+
["done", "succeeded"],
|
|
63
|
+
["error", "failed"],
|
|
64
|
+
["errored", "failed"],
|
|
65
|
+
["active", "running"],
|
|
66
|
+
["in_progress", "running"],
|
|
67
|
+
["in-progress", "running"],
|
|
68
|
+
["working", "running"],
|
|
69
|
+
["pending", "queued"],
|
|
70
|
+
["scheduled", "queued"],
|
|
71
|
+
["wait", "waiting"],
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/** 文本字段归一:只有非空字符串才算「有值」,其余(含数字/对象)统一为 null。 */
|
|
75
|
+
function textOrNull(value) {
|
|
76
|
+
if (typeof value !== "string") return null;
|
|
77
|
+
return value.length === 0 ? null : value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 分片任务名称归一:只有"去掉首尾空白后仍非空"的字符串才算有值。
|
|
82
|
+
*
|
|
83
|
+
* 与 textOrNull 的差别:纯空白名称在这里按缺失处理(回退分片代号),因为它是给用户
|
|
84
|
+
* 看的任务名——一个只有空格的名称在侧栏上等价于空标签,还会多出一个悬空分隔符。
|
|
85
|
+
* 名称内部的大小写与标点一律保留,返回值已 trim。
|
|
86
|
+
*/
|
|
87
|
+
function trimmedNameOrNull(value) {
|
|
88
|
+
if (typeof value !== "string") return null;
|
|
89
|
+
const trimmed = value.trim();
|
|
90
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 时间字段归一:只接受有限数值(epoch 毫秒),其余统一为 null。 */
|
|
94
|
+
function numberOrNull(value) {
|
|
95
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 从候选字段里取第一个非空字符串,全部缺失时返回 null(不拼接、不猜测)。 */
|
|
99
|
+
function firstText(...values) {
|
|
100
|
+
for (const value of values) {
|
|
101
|
+
const text = textOrNull(value);
|
|
102
|
+
if (text !== null) return text;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 状态归一。入参为空 → "unknown";词表内原样通过;别名表命中则归一;
|
|
109
|
+
* 其余值(包括任意自由文本)一律落回 "unknown",绝不猜测。
|
|
110
|
+
*/
|
|
111
|
+
function normalizeStatus(value) {
|
|
112
|
+
const text = textOrNull(value);
|
|
113
|
+
if (text === null) return "unknown";
|
|
114
|
+
const key = text.trim().toLowerCase().replace(/\s+/g, "_");
|
|
115
|
+
if (STATUS_SET.has(key)) return key;
|
|
116
|
+
return STATUS_ALIASES.get(key) ?? "unknown";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* running 布尔语义:
|
|
121
|
+
* - 输入显式给了布尔 running 时以它为准(DSH 会话状态字段);
|
|
122
|
+
* - 否则由**已知**状态推导(running ⟺ true,其余已知状态 ⟺ false);
|
|
123
|
+
* - 状态缺失或未知时返回 null,而不是假的 false——「没写」与「明确没跑」不同。
|
|
124
|
+
*/
|
|
125
|
+
function runningOf(record, status) {
|
|
126
|
+
if (typeof record?.running === "boolean") return record.running;
|
|
127
|
+
if (status === "unknown") return null;
|
|
128
|
+
return status === "running";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** runSummary 是 Host 已派生的只读活动信息,未知值保持 unknown/null,不伪造空闲。 */
|
|
132
|
+
function projectRunSummary(value) {
|
|
133
|
+
const source = asRecord(value);
|
|
134
|
+
if (source === undefined) return null;
|
|
135
|
+
const mode = source.mode === "continuable" || source.mode === "one-shot" ? source.mode : "unknown";
|
|
136
|
+
const activity = source.activity === "running" || source.activity === "idle" ? source.activity : "unknown";
|
|
137
|
+
return {
|
|
138
|
+
command: textOrNull(source.command),
|
|
139
|
+
mode,
|
|
140
|
+
activity,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 身份 id 归一:id → sessionId → childId,全部缺失时返回 null。 */
|
|
145
|
+
function identityOf(record) {
|
|
146
|
+
return firstText(record?.id, record?.sessionId, record?.childId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 参数校验:sessionId 必须是 Expert Group 的根会话键,否则拒绝投影。 */
|
|
150
|
+
function assertSessionId(sessionId) {
|
|
151
|
+
if (!isNonEmpty(sessionId)) {
|
|
152
|
+
throw new TypeError("buildExpertGroupSnapshot 需要非空字符串 sessionId");
|
|
153
|
+
}
|
|
154
|
+
return sessionId;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isNonEmpty(value) {
|
|
158
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 取记录的会话身份。非对象输入(字符串/数字/null)视为「没有记录」,
|
|
163
|
+
* 而不是「用这个值当 id」——投影不允许从负载形状反推身份。
|
|
164
|
+
*/
|
|
165
|
+
function asRecord(value) {
|
|
166
|
+
return value !== null && typeof value === "object" ? value : undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* root/expert 节点共用的字段归一。
|
|
171
|
+
*
|
|
172
|
+
* 字段来源固定为「已声明的同名词」,别名只收敛 DSH 侧真实存在的等价字段:
|
|
173
|
+
* - taskName:taskName/task/title/displayTitle/label/name(与 client.js 的目录读取一致);
|
|
174
|
+
* - content:content/prompt(CONTEXT.md:专家任务内容使用派发 prompt);
|
|
175
|
+
* - effort:reasoningEffort/effort;
|
|
176
|
+
* - contentSource **只认显式字段**:投影不推断内容来自 goal、用户请求还是 prompt。
|
|
177
|
+
*/
|
|
178
|
+
function projectSessionNode(record, { kind, id, sessionId }) {
|
|
179
|
+
const source = asRecord(record);
|
|
180
|
+
const status = normalizeStatus(source?.status);
|
|
181
|
+
return {
|
|
182
|
+
id,
|
|
183
|
+
kind,
|
|
184
|
+
sessionId,
|
|
185
|
+
taskName: firstText(
|
|
186
|
+
source?.taskName,
|
|
187
|
+
source?.task,
|
|
188
|
+
source?.title,
|
|
189
|
+
source?.displayTitle,
|
|
190
|
+
source?.label,
|
|
191
|
+
source?.name,
|
|
192
|
+
),
|
|
193
|
+
content: firstText(source?.content, source?.prompt),
|
|
194
|
+
contentSource: textOrNull(source?.contentSource),
|
|
195
|
+
role: textOrNull(source?.role),
|
|
196
|
+
provider: textOrNull(source?.provider),
|
|
197
|
+
model: textOrNull(source?.model),
|
|
198
|
+
effort: firstText(source?.reasoningEffort, source?.effort),
|
|
199
|
+
status,
|
|
200
|
+
running: runningOf(source, status),
|
|
201
|
+
runSummary: projectRunSummary(source?.runSummary),
|
|
202
|
+
continuable: typeof source?.continuable === "boolean" ? source.continuable : null,
|
|
203
|
+
createdAt: numberOrNull(source?.createdAt),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 单个普通专家节点:id/sessionId 都取会话身份(DSH 中 child agent id 即会话 id)。 */
|
|
208
|
+
function projectExpertNode(record) {
|
|
209
|
+
const source = asRecord(record);
|
|
210
|
+
const identity = identityOf(source);
|
|
211
|
+
return {
|
|
212
|
+
...projectSessionNode(source, { kind: "expert", id: identity, sessionId: identity }),
|
|
213
|
+
// 专家是叶子 worker,本切片不再向下展开子节点。
|
|
214
|
+
children: [],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 非数组输入降级为空集合:调用方给了半截数据时投影仍然可用,但不猜内容。 */
|
|
219
|
+
function asArray(value) {
|
|
220
|
+
return Array.isArray(value) ? value : [];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 普通专家子节点投影。 */
|
|
224
|
+
function projectExpertChildren(experts) {
|
|
225
|
+
return asArray(experts).map((record) => projectExpertNode(record));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── fan-out 分支投影 ────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
/** counts 的固定键集合,与 fanout.js 的 SHARD_STATUS_KEYS + pending 完全一致。 */
|
|
231
|
+
const COUNT_KEYS = Object.freeze([
|
|
232
|
+
"queued",
|
|
233
|
+
"running",
|
|
234
|
+
"succeeded",
|
|
235
|
+
"failed",
|
|
236
|
+
"cancelled",
|
|
237
|
+
"timed_out",
|
|
238
|
+
"interrupted",
|
|
239
|
+
"pending",
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* fan-out manager 的**父任务**状态枚举到统一词表的显式映射。
|
|
244
|
+
*
|
|
245
|
+
* 这是 manager 自己的状态字(fanout.js FANOUT_TASK_STATUSES),不是客户端推断:
|
|
246
|
+
* - pending(仍有未终态 shard)→ running(任务已派发且未收敛);
|
|
247
|
+
* - completed → succeeded;
|
|
248
|
+
* - completed_with_errors → failed(部分成功在统一词表里没有独立项,原始字保留在
|
|
249
|
+
* taskStatus,消费方需要区分时读它);
|
|
250
|
+
* - cancelled → cancelled。
|
|
251
|
+
*/
|
|
252
|
+
const FANOUT_TASK_STATUS_MAP = new Map([
|
|
253
|
+
["pending", "running"],
|
|
254
|
+
["completed", "succeeded"],
|
|
255
|
+
["completed_with_errors", "failed"],
|
|
256
|
+
["cancelled", "cancelled"],
|
|
257
|
+
]);
|
|
258
|
+
|
|
259
|
+
/** 父任务状态归一:先走 manager 枚举表,再退回通用状态词表。 */
|
|
260
|
+
function normalizeTaskStatus(value) {
|
|
261
|
+
const text = textOrNull(value);
|
|
262
|
+
if (text === null) return "unknown";
|
|
263
|
+
const mapped = FANOUT_TASK_STATUS_MAP.get(text.trim().toLowerCase());
|
|
264
|
+
return mapped ?? normalizeStatus(text);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 单次 attempt 投影(attempt 身份 = attempt 序号 + childId)。
|
|
269
|
+
* 只保留导航与状态:result/error 可能很大,且已有独立读取通道,投影不搬运它们。
|
|
270
|
+
*/
|
|
271
|
+
function projectAttemptNode(attempt) {
|
|
272
|
+
const source = asRecord(attempt);
|
|
273
|
+
return {
|
|
274
|
+
attempt: numberOrNull(source?.attempt),
|
|
275
|
+
status: normalizeStatus(source?.status),
|
|
276
|
+
childId: textOrNull(source?.childId),
|
|
277
|
+
startedAt: numberOrNull(source?.startedAt),
|
|
278
|
+
finishedAt: numberOrNull(source?.finishedAt),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 单 shard 投影。
|
|
284
|
+
*
|
|
285
|
+
* 状态优先级:manager 的明确 shard.status 优先;manager 没给(部分输入/旧快照)时,
|
|
286
|
+
* 才取**最新一次 attempt**(输入数组最后一项,manager 保证追加顺序)的状态。
|
|
287
|
+
* 因此「历史 attempt 失败、最新 attempt 成功」必须投影为 succeeded,反之亦然——
|
|
288
|
+
* 历史 attempt 不覆盖当前有效结果,只作为审计信息保留在 attempts 里。
|
|
289
|
+
* childId 同理只取最新 attempt 的,作为唯一导航键。
|
|
290
|
+
*/
|
|
291
|
+
function projectShardNode(shard, inherited = {}) {
|
|
292
|
+
const source = asRecord(shard);
|
|
293
|
+
const defaults = asRecord(inherited);
|
|
294
|
+
const attempts = asArray(source?.attempts).map((attempt) => projectAttemptNode(attempt));
|
|
295
|
+
const latestAttempt = attempts.length === 0 ? null : attempts[attempts.length - 1];
|
|
296
|
+
const shardId = textOrNull(source?.shardId);
|
|
297
|
+
const managerStatus = textOrNull(source?.status);
|
|
298
|
+
const status = managerStatus === null
|
|
299
|
+
? latestAttempt?.status ?? "unknown"
|
|
300
|
+
: normalizeStatus(managerStatus);
|
|
301
|
+
return {
|
|
302
|
+
shardId,
|
|
303
|
+
// 主代理提供的分片任务名称优先(投影出的任务行第一行文本);旧数据没有该字段或
|
|
304
|
+
// 名称是空白串时回退分片代号,保持既有「分片标识即任务名称」的展示语义不变。
|
|
305
|
+
taskName: trimmedNameOrNull(source?.name) ?? shardId,
|
|
306
|
+
prompt: textOrNull(source?.prompt),
|
|
307
|
+
// fan-out 一次提交使用同一专家角色;provider/model/effort 则由 runtime 按
|
|
308
|
+
// 最新 attempt child 补齐。旧快照没有分片级字段时再继承父任务元数据。
|
|
309
|
+
role: firstText(source?.role, defaults?.role),
|
|
310
|
+
provider: firstText(source?.provider, defaults?.provider),
|
|
311
|
+
model: firstText(source?.model, defaults?.model),
|
|
312
|
+
effort: firstText(source?.reasoningEffort, source?.effort, defaults?.reasoningEffort, defaults?.effort),
|
|
313
|
+
status,
|
|
314
|
+
running: runningOf(source, status),
|
|
315
|
+
runSummary: projectRunSummary(source?.runSummary),
|
|
316
|
+
continuable: typeof source?.continuable === "boolean" ? source.continuable : null,
|
|
317
|
+
childId: latestAttempt?.childId ?? null,
|
|
318
|
+
createdAt: numberOrNull(source?.createdAt),
|
|
319
|
+
attempts,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** counts 归一:manager 没给 counts 就保持 null(投影不按 shards 重算);给了则逐键取值,缺失键为 null。 */
|
|
324
|
+
function projectCounts(counts) {
|
|
325
|
+
const source = asRecord(counts);
|
|
326
|
+
if (source === undefined) return null;
|
|
327
|
+
const projected = {};
|
|
328
|
+
for (const key of COUNT_KEYS) projected[key] = numberOrNull(source[key]);
|
|
329
|
+
return projected;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* fan-out 父任务节点投影。
|
|
334
|
+
*
|
|
335
|
+
* taskStatus 保留 manager 的原始字(便于区分 completed / completed_with_errors),
|
|
336
|
+
* status 是统一词表出口。父任务状态**不做 shard 聚合**:manager 没给就是 unknown。
|
|
337
|
+
*/
|
|
338
|
+
function projectFanoutNode(task) {
|
|
339
|
+
const source = asRecord(task);
|
|
340
|
+
const parentTaskId = textOrNull(source?.parentTaskId);
|
|
341
|
+
const taskStatus = textOrNull(source?.status);
|
|
342
|
+
const status = normalizeTaskStatus(taskStatus);
|
|
343
|
+
return {
|
|
344
|
+
// 父任务 id 本身就是稳定身份,可直接作为节点 id(kind 区分它与专家会话 id)。
|
|
345
|
+
id: parentTaskId,
|
|
346
|
+
kind: "fanout",
|
|
347
|
+
parentTaskId,
|
|
348
|
+
basePrompt: textOrNull(source?.basePrompt),
|
|
349
|
+
role: textOrNull(source?.role),
|
|
350
|
+
status,
|
|
351
|
+
taskStatus,
|
|
352
|
+
running: runningOf(source, status),
|
|
353
|
+
createdAt: numberOrNull(source?.createdAt),
|
|
354
|
+
counts: projectCounts(source?.counts),
|
|
355
|
+
shards: asArray(source?.shards).map((shard) => projectShardNode(shard, {
|
|
356
|
+
role: source?.role,
|
|
357
|
+
provider: source?.provider,
|
|
358
|
+
model: source?.model,
|
|
359
|
+
reasoningEffort: source?.reasoningEffort,
|
|
360
|
+
effort: source?.effort,
|
|
361
|
+
})),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 收集所有 fan-out attempt 的 childId。
|
|
367
|
+
*
|
|
368
|
+
* 这些会话已经在 fan-out 分支里以「分片 + attempt」的形式出现,属于同一个 Expert
|
|
369
|
+
* Group 节点的另一种视图;如果同时再作为普通 expert 子节点输出,客户端会看到重复
|
|
370
|
+
* 任务。注意是**任意** attempt(含历史失败 attempt),不只是最新那次。
|
|
371
|
+
*/
|
|
372
|
+
function collectAttemptChildIds(fanoutTasks) {
|
|
373
|
+
const childIds = new Set();
|
|
374
|
+
for (const task of asArray(fanoutTasks)) {
|
|
375
|
+
for (const shard of asArray(asRecord(task)?.shards)) {
|
|
376
|
+
for (const attempt of asArray(asRecord(shard)?.attempts)) {
|
|
377
|
+
const childId = textOrNull(asRecord(attempt)?.childId);
|
|
378
|
+
if (childId !== null) childIds.add(childId);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return childIds;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 合并排序:普通专家与 fan-out 分支统一按 createdAt 升序。
|
|
387
|
+
*
|
|
388
|
+
* 缺失 createdAt 的节点表示「时间未知」,统一排在有序节点之后,并在它们之间保持
|
|
389
|
+
* 输入顺序;相同时间戳也用输入下标兜底,结果不依赖引擎排序实现的稳定性。
|
|
390
|
+
* fan-out 内部的 shards/attempts 不参与该排序,始终保留 manager 顺序。
|
|
391
|
+
*/
|
|
392
|
+
function sortByCreatedAt(nodes) {
|
|
393
|
+
return nodes
|
|
394
|
+
.map((node, index) => ({ node, index }))
|
|
395
|
+
.sort((left, right) => {
|
|
396
|
+
const leftKey = left.node.createdAt === null ? Number.POSITIVE_INFINITY : left.node.createdAt;
|
|
397
|
+
const rightKey = right.node.createdAt === null ? Number.POSITIVE_INFINITY : right.node.createdAt;
|
|
398
|
+
if (leftKey !== rightKey) return leftKey - rightKey;
|
|
399
|
+
return left.index - right.index;
|
|
400
|
+
})
|
|
401
|
+
.map((entry) => entry.node);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* root.children = 普通专家子节点 + fan-out 分支节点。
|
|
406
|
+
* 先按 attempt.childId 去重,再按 createdAt 升序合并。
|
|
407
|
+
*/
|
|
408
|
+
function projectChildren(experts, fanoutTasks) {
|
|
409
|
+
const fanoutChildIds = collectAttemptChildIds(fanoutTasks);
|
|
410
|
+
const expertNodes = projectExpertChildren(experts)
|
|
411
|
+
// 身份缺失的专家无法判定是否与 attempt 重复,保留原样而不是误删。
|
|
412
|
+
.filter((node) => node.sessionId === null || !fanoutChildIds.has(node.sessionId));
|
|
413
|
+
const fanoutNodes = asArray(fanoutTasks).map((task) => projectFanoutNode(task));
|
|
414
|
+
return sortByCreatedAt([...expertNodes, ...fanoutNodes]);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* 构建 Expert Group 快照。
|
|
419
|
+
*
|
|
420
|
+
* @param {object} input
|
|
421
|
+
* @param {string} input.sessionId - Expert Group 的根会话 id(必填,非法即抛 TypeError)。
|
|
422
|
+
* @param {object} [input.root] - 根会话/根 Agent 记录;声明的会话 id 必须与 sessionId 一致。
|
|
423
|
+
* @param {Array} [input.experts] - 普通专家记录(自身状态由 DSH 给出)。
|
|
424
|
+
* @param {Array} [input.fanoutTasks] - fan-out manager 的父任务快照。
|
|
425
|
+
* @param {number} [input.generatedAt] - 投影生成时间(epoch 毫秒)。
|
|
426
|
+
* @returns {object} schemaVersion:1 的只读 JSON 树。
|
|
427
|
+
*/
|
|
428
|
+
export function buildExpertGroupSnapshot({ sessionId, root, experts, fanoutTasks, generatedAt } = {}) {
|
|
429
|
+
const rootSessionId = assertSessionId(sessionId);
|
|
430
|
+
const declaredRootId = identityOf(root);
|
|
431
|
+
if (declaredRootId !== null && declaredRootId !== rootSessionId) {
|
|
432
|
+
throw new TypeError(
|
|
433
|
+
"buildExpertGroupSnapshot 的 root 会话 id " + declaredRootId + " 与 sessionId " + rootSessionId + " 不一致",
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
schemaVersion: EXPERT_GROUP_SNAPSHOT_SCHEMA_VERSION,
|
|
438
|
+
sessionId: rootSessionId,
|
|
439
|
+
rootSessionId,
|
|
440
|
+
generatedAt: numberOrNull(generatedAt),
|
|
441
|
+
root: {
|
|
442
|
+
...projectSessionNode(root, {
|
|
443
|
+
kind: "root",
|
|
444
|
+
id: declaredRootId ?? rootSessionId,
|
|
445
|
+
sessionId: rootSessionId,
|
|
446
|
+
}),
|
|
447
|
+
children: projectChildren(experts, fanoutTasks),
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|