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,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fan-out 数据资源预算:体积测量、冻结预算常量与结构化违规描述。
|
|
3
|
+
*
|
|
4
|
+
* 设计目标(任务书 11):
|
|
5
|
+
* - 只做"测量 + 判定 + 结构化描述",不持有调度状态、不写 storage、不读时钟;
|
|
6
|
+
* - 所有字节预算都必须有可复现的固定 fixture 测量依据(见 RESOURCE_BUDGET_BASELINE),
|
|
7
|
+
* 不允许在别处手写未经测量的"批准常量";
|
|
8
|
+
* - 违规一律表达为结构化对象(code/resource/limit/receivedBytes/complete/recoverable),
|
|
9
|
+
* 可直接进入事件、快照与 RPC 响应,调用方按 code 分支而不解析文案。
|
|
10
|
+
*
|
|
11
|
+
* 测量口径统一为 UTF-8 字节(不是 UTF-16 字符数):中文与 emoji 的字符数会严重
|
|
12
|
+
* 低估真实传输/落盘体积,而 storage、RPC body 与 JSON 快照都按字节计量。
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-expert-group/resource-budget
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** 预算违规的统一 code;RPC/工具层据此映射为显式拒绝。 */
|
|
18
|
+
export const BUDGET_EXCEEDED_CODE = "BUDGET_EXCEEDED";
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 预算常量(冻结)。
|
|
23
|
+
*
|
|
24
|
+
* 每个字节预算都取自 RESOURCE_BUDGET_BASELINE 的同名测量值,并保留 >= 2x 的代表样本
|
|
25
|
+
* 余量;测量 fixture 与重测命令见 test/resource-budget.test.mjs。
|
|
26
|
+
*/
|
|
27
|
+
export const FANOUT_BUDGET = Object.freeze({
|
|
28
|
+
/** 单个父任务的公共目标 + 单个 shard 正文合计上限。 */
|
|
29
|
+
maxTaskInputBytes: 512 * 1024,
|
|
30
|
+
/** 单次 attempt 的 result 上限(超出必须显式失败,不得静默截断)。 */
|
|
31
|
+
maxAttemptResultBytes: 1024 * 1024,
|
|
32
|
+
/** 单次 attempt 的 error 上限(结构化错误必须保持可序列化)。 */
|
|
33
|
+
maxAttemptErrorBytes: 64 * 1024,
|
|
34
|
+
/** 单条事件上限;超出时只发布不含原始负载的诊断事件。 */
|
|
35
|
+
maxEventBytes: 256 * 1024,
|
|
36
|
+
/** 单份可持久化快照上限;超出时不得调用 persistence.save。 */
|
|
37
|
+
maxSnapshotBytes: 4 * 1024 * 1024,
|
|
38
|
+
/** 事件历史的字节预算(条数预算见 maxEventHistory,两者同时生效)。 */
|
|
39
|
+
maxEventHistoryBytes: 2 * 1024 * 1024,
|
|
40
|
+
/** 事件历史条数上限:保留既有 1000 条契约不变。 */
|
|
41
|
+
maxEventHistory: 1000,
|
|
42
|
+
/** 单个 manager 内并发登记的父任务数上限。 */
|
|
43
|
+
maxTasks: 64,
|
|
44
|
+
/** 单个父任务的 shard 数上限。 */
|
|
45
|
+
maxShardsPerTask: 64,
|
|
46
|
+
/** 单 shard 的 attempt 数上限;retry 超限返回结构化失败且不新建 attempt。 */
|
|
47
|
+
maxAttemptsPerShard: 8,
|
|
48
|
+
/** 聚合反馈注入文本上限(既有 120000 字符契约的字节化表达)。 */
|
|
49
|
+
maxFeedbackBytes: 256 * 1024,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 代表样本的测量值(UTF-8 字节)。
|
|
55
|
+
*
|
|
56
|
+
* 口径:固定 fixture,不含随机数与机器相关信息;值为 JSON 序列化后的字节数
|
|
57
|
+
* (与 storage / RPC / 快照一致)。重测方式:
|
|
58
|
+
* node --test test/resource-budget.test.mjs
|
|
59
|
+
* 其中"测量依据可复现"用例会用同一 fixture 重新测量并逐字节比对。
|
|
60
|
+
*/
|
|
61
|
+
export const RESOURCE_BUDGET_BASELINE = Object.freeze({
|
|
62
|
+
/** 典型父任务目标(3 行中文,329B)+ 典型 shard 正文(127B)。 */
|
|
63
|
+
maxTaskInputBytes: 329 + 127,
|
|
64
|
+
/** 典型 5 行专家最终报告(265B)。 */
|
|
65
|
+
sampleResultBytes: 265,
|
|
66
|
+
maxAttemptResultBytes: 265,
|
|
67
|
+
/** 带 25 帧 stack 的错误(1898B)。 */
|
|
68
|
+
maxAttemptErrorBytes: 1898,
|
|
69
|
+
/** 单条带 result 的 shard_status_changed 事件(554B)。 */
|
|
70
|
+
maxEventBytes: 554,
|
|
71
|
+
/** 1 个 4-shard 任务 + 40 条事件的快照(26385B)。 */
|
|
72
|
+
maxSnapshotBytes: 26385,
|
|
73
|
+
/** 1000 条代表性事件(平均 555B/条)的字节量。 */
|
|
74
|
+
maxEventHistoryBytes: 555 * 1000,
|
|
75
|
+
/**
|
|
76
|
+
* 多任务满额 fixture(maxTasks 个已完成任务 × 4 shard × 代表性 result
|
|
77
|
+
* + 恰好 1000 条代表性事件)的固定测量值。它是"业务满载"下的上界依据:
|
|
78
|
+
* 4 MiB 预算相对它有 5.2x 余量,因此常量保持 4 MiB 不变。
|
|
79
|
+
*/
|
|
80
|
+
multiTaskSnapshotBytes: 799745,
|
|
81
|
+
/**
|
|
82
|
+
* 满额事件历史 fixture(1 个 4-shard 任务 + 恰好 1000 条代表性事件的完整快照)
|
|
83
|
+
* 的固定测量值。它是 maxSnapshotBytes 的真正下界依据:恢复时必须能装下整个
|
|
84
|
+
* 允许保留的事件历史,否则"合法的满额历史"永远无法落盘。
|
|
85
|
+
*/
|
|
86
|
+
fullEventHistorySnapshotBytes: 569074,
|
|
87
|
+
/** 该 fixture 使用的固定文本(测试用同一份重建快照并逐字节比对)。 */
|
|
88
|
+
sampleBasePrompt: [
|
|
89
|
+
"实现任务书第 11「建立 fan-out 与 RPC 数据资源预算」。",
|
|
90
|
+
"范围:resource-budget.js(可新增)、fanout.js、fanout-host.js、index.js、package.json。",
|
|
91
|
+
"要求:先测量再冻结常量;prompt 超限必须结构化拒绝;result/error/snapshot 超限必须显式失败或返回 complete=false 元数据。",
|
|
92
|
+
].join("\n"),
|
|
93
|
+
sampleShardPrompt: "只处理 shard a1:读取 host/fanout-runtime.js 中 restore() 的 ownership 释放分支,给出修复方案并补测试。",
|
|
94
|
+
sampleResultText: [
|
|
95
|
+
"任务结果:已修复。",
|
|
96
|
+
"关键修改:restore 保持 ownership;终态释放。",
|
|
97
|
+
"变更文件:host/fanout-runtime.js、test/host-fanout-runtime.test.mjs。",
|
|
98
|
+
"验证:node --test 76/76,pnpm test 598/598。",
|
|
99
|
+
"残余风险:无。",
|
|
100
|
+
].join("\n"),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
/** 单个资源名的可读标签,用于诊断文案。 */
|
|
105
|
+
const RESOURCE_LABELS = Object.freeze({
|
|
106
|
+
task_input: "任务输入(basePrompt + shard prompt)",
|
|
107
|
+
attempt_result: "shard attempt 结果",
|
|
108
|
+
attempt_error: "shard attempt 错误",
|
|
109
|
+
event: "单条 fan-out 事件",
|
|
110
|
+
snapshot: "fan-out 快照",
|
|
111
|
+
event_history: "事件历史",
|
|
112
|
+
tasks: "父任务数量",
|
|
113
|
+
shards: "父任务 shard 数量",
|
|
114
|
+
attempts: "shard attempt 数量",
|
|
115
|
+
feedback: "父会话聚合反馈",
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 测量任意值的 UTF-8 字节数。
|
|
121
|
+
*
|
|
122
|
+
* - string:按 UTF-8 计字节(中文 3 字节/字符、emoji 4 字节/字符);
|
|
123
|
+
* - undefined:0(与 cloneJson 语义一致,该字段不会出现在 JSON 中);
|
|
124
|
+
* - 其它值:按 JSON 序列化计字节;循环引用等不可序列化值返回 null 表示"无法测量"。
|
|
125
|
+
*
|
|
126
|
+
* @param {*} value
|
|
127
|
+
* @returns {number|null} 字节数;null 表示无法测量。
|
|
128
|
+
*/
|
|
129
|
+
export function measureUtf8Bytes(value) {
|
|
130
|
+
if (value === undefined) return 0;
|
|
131
|
+
if (typeof value === "string") return Buffer.byteLength(value, "utf8");
|
|
132
|
+
return measureJsonBytes(value);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 测量 JSON 序列化后的 UTF-8 字节数。
|
|
137
|
+
* @param {*} value
|
|
138
|
+
* @returns {number|null} 字节数;null 表示不可序列化(如循环引用)。
|
|
139
|
+
*/
|
|
140
|
+
export function measureJsonBytes(value) {
|
|
141
|
+
let serialized;
|
|
142
|
+
try {
|
|
143
|
+
serialized = JSON.stringify(value);
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
if (serialized === undefined) return 0;
|
|
148
|
+
return Buffer.byteLength(serialized, "utf8");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 数量/字节是否超限。
|
|
153
|
+
*
|
|
154
|
+
* 数值:严格大于上限才算超限(等于上限是允许的)。
|
|
155
|
+
* null:表示"无法测量"(JSON.stringify 抛错,如循环引用/BigInt/抛错 getter)。
|
|
156
|
+
* 这类值绝不能当作"未知体积所以放过"——它们会在 persistence / cloneJson /
|
|
157
|
+
* RPC 序列化路径上抛出未处理异常,必须显式拒绝。
|
|
158
|
+
*/
|
|
159
|
+
function exceedsBudget(receivedBytes, limit) {
|
|
160
|
+
if (receivedBytes === null) return true;
|
|
161
|
+
return Number.isSafeInteger(receivedBytes) && receivedBytes > limit;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 结构化违规描述:可直接进入事件、快照与 RPC 响应。
|
|
167
|
+
*
|
|
168
|
+
* @param {object} input
|
|
169
|
+
* @param {string} input.resource - 资源名(见 RESOURCE_LABELS)。
|
|
170
|
+
* @param {number} input.limit - 上限(字节或数量)。
|
|
171
|
+
* @param {number} [input.receivedBytes] - 实测体积/数量;未知记 null。
|
|
172
|
+
* @param {boolean} [input.complete] - 数据是否完整(违规一律 false)。
|
|
173
|
+
* @param {boolean} [input.recoverable] - 完整内容是否仍可从其它权威来源获取。
|
|
174
|
+
* @param {string} [input.subject] - 违规主体(如 shardId / parentTaskId)。
|
|
175
|
+
* @param {string} [input.hint] - 可执行的后续动作提示。
|
|
176
|
+
* @returns {object} 冻结的违规描述(JSON 可往返)。
|
|
177
|
+
*/
|
|
178
|
+
export function describeBudgetViolation({ resource, limit, receivedBytes, measured, complete = false, recoverable = false, subject, hint } = {}) {
|
|
179
|
+
const label = RESOURCE_LABELS[resource] ?? String(resource ?? "resource");
|
|
180
|
+
const size = Number.isSafeInteger(receivedBytes) ? receivedBytes : null;
|
|
181
|
+
// measured=false 且 size=null:体积无法测量(不可 JSON 序列化),语义与"超限"不同,
|
|
182
|
+
// 必须让调用方与用户都能区分。
|
|
183
|
+
const unmeasurable = measured === false || (measured !== true && size === null);
|
|
184
|
+
const message = unmeasurable
|
|
185
|
+
? "超出预算: " + label + " 无法测量(不可 JSON 序列化)> 上限 " + String(limit) + "(resource=" + String(resource) + ")"
|
|
186
|
+
: "超出预算: " + label + " 实测 " + (size === null ? "未知" : String(size))
|
|
187
|
+
+ " > 上限 " + String(limit) + "(resource=" + String(resource) + ")";
|
|
188
|
+
return Object.freeze({
|
|
189
|
+
code: BUDGET_EXCEEDED_CODE,
|
|
190
|
+
resource: String(resource ?? "resource"),
|
|
191
|
+
label,
|
|
192
|
+
limit,
|
|
193
|
+
maxBytes: limit,
|
|
194
|
+
receivedBytes: size,
|
|
195
|
+
...(unmeasurable ? { unmeasurable: true } : {}),
|
|
196
|
+
complete: complete === true,
|
|
197
|
+
recoverable: recoverable === true,
|
|
198
|
+
subject: subject === undefined || subject === null ? null : String(subject),
|
|
199
|
+
message,
|
|
200
|
+
...(typeof hint === "string" && hint.length > 0 ? { hint } : {}),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 预算拒绝错误。字段与 describeBudgetViolation 一致,violation 保留完整描述,
|
|
207
|
+
* 便于把同一份元数据同时放进错误、事件与快照。
|
|
208
|
+
*/
|
|
209
|
+
export class BudgetExceededError extends Error {
|
|
210
|
+
constructor(options = {}) {
|
|
211
|
+
const violation = describeBudgetViolation(options);
|
|
212
|
+
super(violation.message);
|
|
213
|
+
this.name = "BudgetExceededError";
|
|
214
|
+
this.code = violation.code;
|
|
215
|
+
this.resource = violation.resource;
|
|
216
|
+
this.limit = violation.limit;
|
|
217
|
+
this.maxBytes = violation.maxBytes;
|
|
218
|
+
this.receivedBytes = violation.receivedBytes;
|
|
219
|
+
this.complete = violation.complete;
|
|
220
|
+
this.recoverable = violation.recoverable;
|
|
221
|
+
this.subject = violation.subject;
|
|
222
|
+
if (violation.hint !== undefined) this.hint = violation.hint;
|
|
223
|
+
this.violation = violation;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 判定一次测量是否超限,返回统一判定结果(ok=false 时带 violation)。
|
|
229
|
+
*
|
|
230
|
+
* @param {{resource:string, limit:number, receivedBytes:number|null, complete?:boolean,
|
|
231
|
+
* recoverable?:boolean, subject?:string, hint?:string}} input
|
|
232
|
+
* @returns {{ok:boolean, violation?:object}}
|
|
233
|
+
*/
|
|
234
|
+
export function checkBudget(input = {}) {
|
|
235
|
+
const { resource, limit, receivedBytes } = input;
|
|
236
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
237
|
+
throw new TypeError("checkBudget 需要正整数的 limit");
|
|
238
|
+
}
|
|
239
|
+
if (!exceedsBudget(receivedBytes, limit)) return { ok: true };
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
violation: describeBudgetViolation({
|
|
243
|
+
...input,
|
|
244
|
+
complete: false,
|
|
245
|
+
// 保留"不可测量而非超限"的判定依据(measured:false 覆盖 receivedBytes 推断)。
|
|
246
|
+
...(receivedBytes === null ? { measured: false } : {}),
|
|
247
|
+
}),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 校验任务输入(basePrompt + 单个 shard prompt)是否在预算内。
|
|
254
|
+
* 输入超限必须显式拒绝:不能截断后继续执行,否则专家会基于不完整的目标工作。
|
|
255
|
+
*
|
|
256
|
+
* @param {object} input - { basePrompt, shardPrompt, subject }
|
|
257
|
+
* @returns {{ok:boolean, receivedBytes:number|null, violation?:object}}
|
|
258
|
+
*/
|
|
259
|
+
export function checkTaskInputBudget({ basePrompt, shardPrompt, subject } = {}) {
|
|
260
|
+
const baseBytes = measureUtf8Bytes(basePrompt);
|
|
261
|
+
const shardBytes = measureUtf8Bytes(shardPrompt);
|
|
262
|
+
const receivedBytes = baseBytes === null || shardBytes === null ? null : baseBytes + shardBytes;
|
|
263
|
+
const verdict = checkBudget({
|
|
264
|
+
resource: "task_input",
|
|
265
|
+
limit: FANOUT_BUDGET.maxTaskInputBytes,
|
|
266
|
+
receivedBytes,
|
|
267
|
+
recoverable: true,
|
|
268
|
+
subject,
|
|
269
|
+
hint: "缩小 basePrompt / shard prompt,或把大段材料放进专家可自行读取的文件",
|
|
270
|
+
});
|
|
271
|
+
return { ...verdict, receivedBytes };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 校验单次 attempt 的 result / error 体积。
|
|
276
|
+
*
|
|
277
|
+
* 两者都必须显式判定:超限时调用方把 shard 收敛为 failed 终态并携带本描述
|
|
278
|
+
* (complete=false + receivedBytes/maxBytes),不得写入被截断的"完整结果"。
|
|
279
|
+
* 不可 JSON 序列化的 result/error(循环引用、BigInt、抛错 getter)返回
|
|
280
|
+
* receivedBytes=null 且同样判为违规:它们会在 cloneJson/persistence 路径抛异常。
|
|
281
|
+
*
|
|
282
|
+
* @param {object} input - { result, error, subject }
|
|
283
|
+
* @returns {{ok:boolean, resultBytes:number|null, errorBytes:number|null, violation?:object}}
|
|
284
|
+
*/
|
|
285
|
+
export function checkAttemptPayloadBudget({ result, error, subject } = {}) {
|
|
286
|
+
const resultBytes = result === undefined ? 0 : measureJsonBytes(result);
|
|
287
|
+
const errorBytes = error === undefined ? 0 : measureJsonBytes(error);
|
|
288
|
+
const resultVerdict = checkBudget({
|
|
289
|
+
resource: "attempt_result",
|
|
290
|
+
limit: FANOUT_BUDGET.maxAttemptResultBytes,
|
|
291
|
+
receivedBytes: resultBytes,
|
|
292
|
+
subject,
|
|
293
|
+
// 结果超限是"可恢复"的:完整输出仍留在 child 会话历史里,主代理可自行读取。
|
|
294
|
+
recoverable: true,
|
|
295
|
+
hint: "结果过大时请让专家把完整内容写入文件,只在最终报告里给出路径与摘要",
|
|
296
|
+
});
|
|
297
|
+
if (!resultVerdict.ok) return { ...resultVerdict, resultBytes, errorBytes };
|
|
298
|
+
const errorVerdict = checkBudget({
|
|
299
|
+
resource: "attempt_error",
|
|
300
|
+
limit: FANOUT_BUDGET.maxAttemptErrorBytes,
|
|
301
|
+
receivedBytes: errorBytes,
|
|
302
|
+
subject,
|
|
303
|
+
});
|
|
304
|
+
return { ...errorVerdict, resultBytes, errorBytes };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 校验单条事件的体积(不含 sequence/at 之前的原始负载)。
|
|
309
|
+
* @param {object} event
|
|
310
|
+
* @param {{subject?:string}} [options]
|
|
311
|
+
*/
|
|
312
|
+
export function checkEventBudget(event, { subject } = {}) {
|
|
313
|
+
return checkBudget({
|
|
314
|
+
resource: "event",
|
|
315
|
+
limit: FANOUT_BUDGET.maxEventBytes,
|
|
316
|
+
receivedBytes: measureJsonBytes(event),
|
|
317
|
+
subject,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 校验可持久化快照的体积。
|
|
323
|
+
* @param {object} snapshot
|
|
324
|
+
* @param {{maxBytes?:number}} [limits] - 上限覆盖(组合根/测试可收紧)。
|
|
325
|
+
*/
|
|
326
|
+
export function checkSnapshotBudget(snapshot, limits = {}) {
|
|
327
|
+
return checkBudget({
|
|
328
|
+
resource: "snapshot",
|
|
329
|
+
limit: Number.isSafeInteger(limits.maxBytes) && limits.maxBytes > 0
|
|
330
|
+
? limits.maxBytes
|
|
331
|
+
: FANOUT_BUDGET.maxSnapshotBytes,
|
|
332
|
+
receivedBytes: measureJsonBytes(snapshot),
|
|
333
|
+
recoverable: false,
|
|
334
|
+
hint: "快照过大时先让未终态 work 收敛,或清理已结算的历史任务",
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 恢复预算:对来自持久化的外部快照做整体判定。
|
|
341
|
+
*
|
|
342
|
+
* 恢复输入不受本进程控制(磁盘/其它版本/被篡改),因此必须在**任何状态修改与大
|
|
343
|
+
* 规模 clone 之前**判定:结构规模(task/shard/attempt 数)、总字节、事件历史条数与
|
|
344
|
+
* 字节。任何一项超限都返回 ok=false,由调用方原子失败(不写入 tasks/eventHistory)。
|
|
345
|
+
*
|
|
346
|
+
* 与 checkEventHistoryBudget 的区别:后者用于运行时"裁剪最旧事件",本函数用于
|
|
347
|
+
* "恢复输入是否可接受"——超限即拒绝,不静默丢弃历史。
|
|
348
|
+
*
|
|
349
|
+
* @param {object} snapshot - 待恢复快照。
|
|
350
|
+
* @param {{existingTasks?:number, maxBytes?:number}} [options]
|
|
351
|
+
* existingTasks:merge 恢复时已有的 task 数(计入总量上限)。
|
|
352
|
+
* @returns {{ok:boolean, tasks:number, shards:number, attempts:number, events:number,
|
|
353
|
+
* eventBytes:number, snapshotBytes:number|null, violation?:object}}
|
|
354
|
+
*/
|
|
355
|
+
export function checkRestoreBudget(snapshot, options = {}) {
|
|
356
|
+
const tasks = Array.isArray(snapshot?.tasks) ? snapshot.tasks : [];
|
|
357
|
+
const events = Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
358
|
+
const existingTasks = Number.isSafeInteger(options.existingTasks) && options.existingTasks > 0
|
|
359
|
+
? options.existingTasks
|
|
360
|
+
: 0;
|
|
361
|
+
const snapshotBytes = measureJsonBytes(snapshot);
|
|
362
|
+
const eventBytes = events.reduce((total, event) => total + (measureJsonBytes(event) ?? 0), 0);
|
|
363
|
+
let shardCount = 0;
|
|
364
|
+
let attemptCount = 0;
|
|
365
|
+
for (const task of tasks) {
|
|
366
|
+
const shards = Array.isArray(task?.shards) ? task.shards : [];
|
|
367
|
+
shardCount += shards.length;
|
|
368
|
+
for (const shard of shards) {
|
|
369
|
+
attemptCount += Array.isArray(shard?.attempts) ? shard.attempts.length : 0;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const summary = {
|
|
373
|
+
ok: true,
|
|
374
|
+
tasks: tasks.length,
|
|
375
|
+
shards: shardCount,
|
|
376
|
+
attempts: attemptCount,
|
|
377
|
+
events: events.length,
|
|
378
|
+
eventBytes,
|
|
379
|
+
snapshotBytes,
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// 快照整体字节(含所有事件与任务负载)。null = 不可序列化,必须拒绝。
|
|
383
|
+
const sizeVerdict = checkBudget({
|
|
384
|
+
resource: "snapshot",
|
|
385
|
+
limit: Number.isSafeInteger(options.maxBytes) && options.maxBytes > 0 ? options.maxBytes : FANOUT_BUDGET.maxSnapshotBytes,
|
|
386
|
+
receivedBytes: snapshotBytes,
|
|
387
|
+
recoverable: false,
|
|
388
|
+
subject: "restore",
|
|
389
|
+
hint: "快照过大时先让未终态 work 收敛,或清理已结算的历史任务",
|
|
390
|
+
});
|
|
391
|
+
if (!sizeVerdict.ok) return { ...summary, ok: false, violation: sizeVerdict.violation };
|
|
392
|
+
|
|
393
|
+
// task 数:默认(非 merge)恢复会整体替换,因此按快照自身计数;merge 时累加现有量。
|
|
394
|
+
const taskVerdict = checkBudget({
|
|
395
|
+
resource: "tasks",
|
|
396
|
+
limit: FANOUT_BUDGET.maxTasks,
|
|
397
|
+
receivedBytes: tasks.length + existingTasks,
|
|
398
|
+
recoverable: false,
|
|
399
|
+
subject: "restore",
|
|
400
|
+
hint: "快照中的父任务数超过上限,请清理已结算的历史任务",
|
|
401
|
+
});
|
|
402
|
+
if (!taskVerdict.ok) return { ...summary, ok: false, violation: taskVerdict.violation };
|
|
403
|
+
|
|
404
|
+
for (const task of tasks) {
|
|
405
|
+
const shards = Array.isArray(task?.shards) ? task.shards : [];
|
|
406
|
+
const shardVerdict = checkBudget({
|
|
407
|
+
resource: "shards",
|
|
408
|
+
limit: FANOUT_BUDGET.maxShardsPerTask,
|
|
409
|
+
receivedBytes: shards.length,
|
|
410
|
+
recoverable: false,
|
|
411
|
+
subject: task?.parentTaskId,
|
|
412
|
+
hint: "单个父任务的 shard 数超过上限",
|
|
413
|
+
});
|
|
414
|
+
if (!shardVerdict.ok) return { ...summary, ok: false, violation: shardVerdict.violation };
|
|
415
|
+
for (const shard of shards) {
|
|
416
|
+
const attempts = Array.isArray(shard?.attempts) ? shard.attempts : [];
|
|
417
|
+
const attemptVerdict = checkBudget({
|
|
418
|
+
resource: "attempts",
|
|
419
|
+
limit: FANOUT_BUDGET.maxAttemptsPerShard,
|
|
420
|
+
receivedBytes: attempts.length,
|
|
421
|
+
recoverable: false,
|
|
422
|
+
subject: (task?.parentTaskId ?? "") + "/" + (shard?.shardId ?? ""),
|
|
423
|
+
hint: "快照中的 attempt 数超过上限",
|
|
424
|
+
});
|
|
425
|
+
if (!attemptVerdict.ok) return { ...summary, ok: false, violation: attemptVerdict.violation };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 事件历史:条数与字节都必须可恢复。超限即拒绝,不做静默丢弃。
|
|
430
|
+
const eventVerdict = checkBudget({
|
|
431
|
+
resource: "event_history",
|
|
432
|
+
limit: FANOUT_BUDGET.maxEventHistoryBytes,
|
|
433
|
+
receivedBytes: eventBytes,
|
|
434
|
+
recoverable: false,
|
|
435
|
+
subject: "restore",
|
|
436
|
+
hint: "快照中的事件历史超出字节预算",
|
|
437
|
+
});
|
|
438
|
+
if (!eventVerdict.ok) return { ...summary, ok: false, violation: eventVerdict.violation };
|
|
439
|
+
const eventCountVerdict = checkBudget({
|
|
440
|
+
resource: "event_history",
|
|
441
|
+
limit: FANOUT_BUDGET.maxEventHistory,
|
|
442
|
+
receivedBytes: events.length,
|
|
443
|
+
recoverable: false,
|
|
444
|
+
subject: "restore",
|
|
445
|
+
hint: "快照中的事件条数超出上限",
|
|
446
|
+
});
|
|
447
|
+
if (!eventCountVerdict.ok) return { ...summary, ok: false, violation: eventCountVerdict.violation };
|
|
448
|
+
|
|
449
|
+
return summary;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* 按 UTF-8 字节预算截断聚合反馈文本。
|
|
454
|
+
*
|
|
455
|
+
* 既有契约(120000 字符上限 + "完整结果可按 parentTaskId 查询")必须保留,但字符数
|
|
456
|
+
* 不等于字节数:中文/emoji 会显著超出字节预算。因此这里以字节为准截断,并在结果里
|
|
457
|
+
* 明确给出 complete=false 与可定位的元数据;hint 里始终保留 parentTaskId,
|
|
458
|
+
* 保证主代理仍能查到完整快照。
|
|
459
|
+
*
|
|
460
|
+
* @param {{parentTaskId?:string, text:string}} input
|
|
461
|
+
* @param {{maxBytes?:number}} [limits]
|
|
462
|
+
* @returns {{text:string, bytes:number, receivedBytes:number, maxBytes:number,
|
|
463
|
+
* complete:boolean, recoverable:boolean, truncated:boolean}}
|
|
464
|
+
*/
|
|
465
|
+
export function truncateFeedbackText({ parentTaskId, text } = {}, limits = {}) {
|
|
466
|
+
const maxBytes = Number.isSafeInteger(limits.maxBytes) && limits.maxBytes > 0
|
|
467
|
+
? limits.maxBytes
|
|
468
|
+
: FANOUT_BUDGET.maxFeedbackBytes;
|
|
469
|
+
const source = typeof text === "string" ? text : "";
|
|
470
|
+
const receivedBytes = Buffer.byteLength(source, "utf8");
|
|
471
|
+
const hint = "\n[结果已截断,请按 parentTaskId 查询完整快照]" + (typeof parentTaskId === "string" && parentTaskId.length > 0 ? ":" + parentTaskId : ":");
|
|
472
|
+
if (receivedBytes + Buffer.byteLength(hint, "utf8") <= maxBytes) {
|
|
473
|
+
return {
|
|
474
|
+
text: source + (receivedBytes === 0 ? "" : ""),
|
|
475
|
+
bytes: receivedBytes,
|
|
476
|
+
receivedBytes,
|
|
477
|
+
maxBytes,
|
|
478
|
+
complete: true,
|
|
479
|
+
recoverable: true,
|
|
480
|
+
truncated: false,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
const hintBytes = Buffer.byteLength(hint, "utf8");
|
|
484
|
+
const budget = Math.max(maxBytes - hintBytes, 0);
|
|
485
|
+
// 用 Buffer 解码保证落在字符边界上(不会把一个多字节字符切成半个)。
|
|
486
|
+
const truncatedText = Buffer.from(source, "utf8").subarray(0, budget).toString("utf8").replace(/\uFFFD+$/u, "");
|
|
487
|
+
return {
|
|
488
|
+
text: truncatedText + hint,
|
|
489
|
+
bytes: Buffer.byteLength(truncatedText + hint, "utf8"),
|
|
490
|
+
receivedBytes,
|
|
491
|
+
maxBytes,
|
|
492
|
+
complete: false,
|
|
493
|
+
recoverable: true,
|
|
494
|
+
truncated: true,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* 校验事件历史(条数 + 字节)并给出需要丢弃的最旧事件数。
|
|
500
|
+
*
|
|
501
|
+
* @param {object[]} events
|
|
502
|
+
* @param {{maxCount?:number, maxBytes?:number}} [limits]
|
|
503
|
+
* @returns {{count:number, bytes:number, droppedCount:number, droppedBytes:number, violation:object|null}}
|
|
504
|
+
*/
|
|
505
|
+
export function checkEventHistoryBudget(events, limits = {}) {
|
|
506
|
+
const list = Array.isArray(events) ? events : [];
|
|
507
|
+
const maxCount = Number.isSafeInteger(limits.maxCount) ? limits.maxCount : FANOUT_BUDGET.maxEventHistory;
|
|
508
|
+
const maxBytes = Number.isSafeInteger(limits.maxBytes) ? limits.maxBytes : FANOUT_BUDGET.maxEventHistoryBytes;
|
|
509
|
+
const sizes = list.map((event) => measureJsonBytes(event) ?? 0);
|
|
510
|
+
const bytes = sizes.reduce((total, size) => total + size, 0);
|
|
511
|
+
let keep = list.length;
|
|
512
|
+
let droppedCount = 0;
|
|
513
|
+
let droppedBytes = 0;
|
|
514
|
+
// 先按条数裁剪,再按字节裁剪;两条预算同时生效。
|
|
515
|
+
while (keep > maxCount) {
|
|
516
|
+
keep -= 1;
|
|
517
|
+
droppedBytes += sizes[droppedCount];
|
|
518
|
+
droppedCount += 1;
|
|
519
|
+
}
|
|
520
|
+
let remainingBytes = bytes - droppedBytes;
|
|
521
|
+
let cursor = droppedCount;
|
|
522
|
+
while (remainingBytes > maxBytes && cursor < list.length) {
|
|
523
|
+
remainingBytes -= sizes[cursor];
|
|
524
|
+
droppedBytes += sizes[cursor];
|
|
525
|
+
cursor += 1;
|
|
526
|
+
keep -= 1;
|
|
527
|
+
}
|
|
528
|
+
const trimmed = droppedCount > 0;
|
|
529
|
+
const violation = trimmed
|
|
530
|
+
? describeBudgetViolation({
|
|
531
|
+
resource: "event_history",
|
|
532
|
+
limit: maxBytes,
|
|
533
|
+
receivedBytes: bytes,
|
|
534
|
+
recoverable: false,
|
|
535
|
+
subject: "event_history",
|
|
536
|
+
hint: "事件历史已按条数/字节预算裁剪最旧记录",
|
|
537
|
+
})
|
|
538
|
+
: null;
|
|
539
|
+
return { count: Math.max(keep, 0), bytes, droppedCount, droppedBytes, violation };
|
|
540
|
+
}
|