@feng0531/dsh-execution-bus 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -4
- package/dist/bus.d.ts +14 -43
- package/dist/bus.js +137 -155
- package/dist/diagnostics.js +6 -4
- package/dist/error-codes.d.ts +54 -0
- package/dist/error-codes.js +60 -0
- package/dist/execution.d.ts +8 -0
- package/dist/execution.js +67 -48
- package/dist/handlers.js +14 -10
- package/dist/json.d.ts +1 -1
- package/dist/json.js +13 -11
- package/dist/limits.d.ts +20 -0
- package/dist/limits.js +23 -0
- package/dist/mode.d.ts +5 -0
- package/dist/mode.js +16 -0
- package/dist/plugin.js +65 -63
- package/dist/preset.d.ts +1 -1
- package/dist/preset.js +23 -20
- package/dist/product.d.ts +56 -0
- package/dist/product.js +48 -0
- package/dist/protocol.d.ts +49 -0
- package/dist/protocol.js +54 -0
- package/dist/test-mode.d.ts +2 -5
- package/dist/test-mode.js +2 -15
- package/docs/PRODUCT.md +46 -0
- package/docs/releases/0.5.1.md +18 -0
- package/docs/releases/0.6.0.md +20 -0
- package/package.json +20 -19
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
“萧何”是产品与模式显示名,执行总线是其技术功能;不新增人物 Agent,不进行角色扮演。仓库 dsh-execution-bus、npm 包名、execution_bus 工具名及 execution-bus-test 预设 ID 保持不变。历史版本发布说明保留当时名称。
|
|
6
6
|
|
|
7
|
-
DSH 独立插件:`@feng0531/dsh-execution-bus` · 当前版本 **0.
|
|
7
|
+
DSH 独立插件:`@feng0531/dsh-execution-bus` · 当前版本 **0.6.0**
|
|
8
8
|
|
|
9
9
|
> 萧何模式从宿主已声明的 PTC 组合派生,保留日常工具,再叠加可选执行总线。已验证隔离的真实 Node PTC → 原 read + 总线调用,以及脚本模型内存会话;真实模型、桌面界面与实际沙箱仍未验收。**不支持重启后恢复任务。**
|
|
10
10
|
|
|
@@ -83,13 +83,22 @@ flowchart TD
|
|
|
83
83
|
|
|
84
84
|
| Handler 名称 | 用途 | 输入 |
|
|
85
85
|
|---|---|---|
|
|
86
|
-
| `test
|
|
86
|
+
| `test`(诊断,非业务) | 仅在明确诊断时检查延迟、进度、成功、失败和取消 | `delayMs`:0–30000;`fail`:布尔值 |
|
|
87
87
|
| `host.read` | 经由 DSH 现有 `read` 工具读取文本 | `file_path`;可选 `offset` 和 `limit`,最多 100 行 |
|
|
88
88
|
|
|
89
89
|
`host.read` 在出队时重新进入宿主工具管线,沿用宿主当前权限、会话工作目录及输出限制。插件不自行启动命令、不提供任意远程执行服务。它也不会强制将现有工具改成总线调用。
|
|
90
90
|
|
|
91
91
|
## 选择“萧何”预设
|
|
92
92
|
|
|
93
|
+
正式模式入口为 `/mode`。新生成预设在原 PTC 组合末尾追加以下行,它不是整份预设:
|
|
94
|
+
|
|
95
|
+
```yaml
|
|
96
|
+
- id: xiao-he-execution-bus
|
|
97
|
+
name: '@feng0531/dsh-execution-bus/mode'
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
旧 /test-mode 保留为兼容别名,两个入口指向同一正式模块,不表示测试专用模式,也不要同时配置新旧两行。必须先安装 0.5.1 或以上才能使用 /mode;旧包没有此导出。旧预设不会自动重写,旧入口继续有效。历史预设 ID execution-bus-test 和包级配置行 ID 不改,避免已有会话失联。
|
|
101
|
+
|
|
93
102
|
包内提供标准 DSH bundle patch(预设组合补丁)。宿主正常加载后会收到“萧何 / Xiao He”预设定义,由既有设置 → Agent 预设 → **自定义**列表展示。建议选择它新建专门会话,不改默认选择或正在使用的普通会话。已有会话的切换受宿主空白会话检查约束。**实际桌面卡片展示尚未验收**,不能把注册表测试当作界面已通过。
|
|
94
103
|
|
|
95
104
|
**0.3.0 起不再是只读验证模式。** 加载时读取宿主 `ptc` 预设的组合声明快照,保留原工具、平台表达式、禁用项和配置,只追加萧何总线与说明。不再强制 native 呈现或添加只读白名单;终端、写入、搜索、技能等能力是否可用,取决于原 PTC 配置和宿主服务。其他预设和默认模式不被修改,原 PTC 会话看不到新增总线。
|
|
@@ -115,7 +124,7 @@ flowchart TD
|
|
|
115
124
|
2. 在你选定的隔离 DSH 配置依赖目录安装固定版本,不要在 DSH 程序安装目录执行:
|
|
116
125
|
|
|
117
126
|
```powershell
|
|
118
|
-
npm install --ignore-scripts @feng0531/dsh-execution-bus@0.
|
|
127
|
+
npm install --ignore-scripts @feng0531/dsh-execution-bus@0.6.0
|
|
119
128
|
```
|
|
120
129
|
|
|
121
130
|
这是 npm 目录示例。若目标配置使用 pnpm,应沿用其包管理器,不混用锁文件。遇到宿主 peer 版本冲突就停止,不用强制安装绕过。
|
|
@@ -254,9 +263,11 @@ npm run test:install # 临时目录安装,不操作日常 DSH
|
|
|
254
263
|
|
|
255
264
|
## 进一步阅读
|
|
256
265
|
|
|
266
|
+
- [产品结构与兼容契约](<docs/PRODUCT.md>):正式入口、模块职责及维护验收边界。
|
|
267
|
+
|
|
257
268
|
- [协议和投递语义](<docs/protocol.md>):字段、状态、重复处理与取消规则。
|
|
258
269
|
- [宿主兼容性与源码调用链](<docs/host-compatibility.md>):为什么可以等待,为什么不能跨重启恢复。
|
|
259
|
-
- [验证记录](<docs/validation.md>)、[0.
|
|
270
|
+
- [验证记录](<docs/validation.md>)、[0.6.0 发布说明](<docs/releases/0.6.0.md>):已验证与未验证范围。
|
|
260
271
|
- [同类项目源码整合](<docs/INTEGRATION.md>):固定来源、采用范围、许可与验证边界。
|
|
261
272
|
- [维护者发布流程](<docs/RELEASING.md>):标签 CI、安装包校验与正式发布门禁。
|
|
262
273
|
|
package/dist/bus.d.ts
CHANGED
|
@@ -1,50 +1,11 @@
|
|
|
1
|
+
import { object, parse } from "./protocol.js";
|
|
2
|
+
import type { Capability, Event, Handler, Message, Owner, RecordView, State } from "./protocol.js";
|
|
3
|
+
export { object, parse };
|
|
4
|
+
export type { Capability, Event, Handler, Message, Owner, RecordView, State };
|
|
1
5
|
export interface AdmissionPolicy {
|
|
2
6
|
sessionQueueLimit?: number;
|
|
3
7
|
maxQueueWaitMs?: number;
|
|
4
8
|
}
|
|
5
|
-
export type State = "queued" | "running" | "succeeded" | "failed" | "cancelled" | "unknown";
|
|
6
|
-
export type Owner = {
|
|
7
|
-
sessionId: string;
|
|
8
|
-
callId: string;
|
|
9
|
-
};
|
|
10
|
-
export type Message = {
|
|
11
|
-
protocolVersion: 1;
|
|
12
|
-
messageId: string;
|
|
13
|
-
requestId: string;
|
|
14
|
-
type: "request" | "status" | "result" | "cancel";
|
|
15
|
-
payload: Record<string, unknown>;
|
|
16
|
-
};
|
|
17
|
-
export type Event = {
|
|
18
|
-
protocolVersion: 1;
|
|
19
|
-
messageId: string;
|
|
20
|
-
requestId: string;
|
|
21
|
-
correlationId: string;
|
|
22
|
-
sequence: number;
|
|
23
|
-
type: string;
|
|
24
|
-
payload: Record<string, unknown>;
|
|
25
|
-
};
|
|
26
|
-
export type Capability = (input: Record<string, unknown>, signal: AbortSignal) => Promise<unknown>;
|
|
27
|
-
export interface Handler {
|
|
28
|
-
validate(input: Record<string, unknown>): void;
|
|
29
|
-
run(input: Record<string, unknown>, ctx: {
|
|
30
|
-
signal: AbortSignal;
|
|
31
|
-
progress: (sequence: number, percent: number) => void;
|
|
32
|
-
requestId: string;
|
|
33
|
-
invoke?: Capability;
|
|
34
|
-
}): Promise<unknown>;
|
|
35
|
-
}
|
|
36
|
-
export interface RecordView {
|
|
37
|
-
requestId: string;
|
|
38
|
-
state: State;
|
|
39
|
-
sequence: number;
|
|
40
|
-
cancelRequested: boolean;
|
|
41
|
-
delivery: "pending" | "returned";
|
|
42
|
-
result?: unknown;
|
|
43
|
-
error?: string;
|
|
44
|
-
progress?: number;
|
|
45
|
-
}
|
|
46
|
-
export declare function object(v: unknown): Record<string, unknown>;
|
|
47
|
-
export declare function parse(raw: unknown): Message;
|
|
48
9
|
/** Volatile single-process bus; no acceptance durability or automatic execution retry. */
|
|
49
10
|
export declare class ExecutionBus {
|
|
50
11
|
readonly options: {
|
|
@@ -108,6 +69,14 @@ export declare class ExecutionBus {
|
|
|
108
69
|
detach: () => void;
|
|
109
70
|
returned: () => void;
|
|
110
71
|
};
|
|
72
|
+
/** 校验并拆解 request 的 payload,返回 handler 名、入参与原文。 */
|
|
73
|
+
private requestParts;
|
|
74
|
+
/** 同一 requestId 的重复提交:校验一致性后复用执行或结果。 */
|
|
75
|
+
private reuseInvocation;
|
|
76
|
+
/** 受理全新请求:校验 Handler、检查容量、构建记录并入队。 */
|
|
77
|
+
private admitInvocation;
|
|
78
|
+
/** 若启用了排队期限,登记到期结算为 QUEUE_WAIT_TIMEOUT 的计时器。 */
|
|
79
|
+
private scheduleQueueDeadline;
|
|
111
80
|
private attachment;
|
|
112
81
|
command(owner: Owner, raw: unknown): RecordView;
|
|
113
82
|
private removeQueued;
|
|
@@ -140,6 +109,8 @@ export declare class ExecutionBus {
|
|
|
140
109
|
};
|
|
141
110
|
private cancel;
|
|
142
111
|
private finish;
|
|
112
|
+
/** 按会话轮转挑选下一个出队项:各会话内部 FIFO,跨会话公平。 */
|
|
113
|
+
private nextQueuedIndex;
|
|
143
114
|
private pump;
|
|
144
115
|
private run;
|
|
145
116
|
close(): Promise<void>;
|
package/dist/bus.js
CHANGED
|
@@ -1,59 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { assertJson } from "./json.js";
|
|
3
|
-
import { Diagnostics } from "./diagnostics.js";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
2
|
import { performance } from "node:perf_hooks";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return ("
|
|
16
|
-
Object.keys(v)
|
|
17
|
-
.sort()
|
|
18
|
-
.map((k) => JSON.stringify(k) +
|
|
19
|
-
":" +
|
|
20
|
-
canonical(v[k]))
|
|
21
|
-
.join(",") +
|
|
22
|
-
"}");
|
|
23
|
-
}
|
|
24
|
-
export function parse(raw) {
|
|
25
|
-
assertJson(raw);
|
|
26
|
-
const m = object(raw);
|
|
27
|
-
if (Object.keys(m).some((k) => ![
|
|
28
|
-
"protocolVersion",
|
|
29
|
-
"messageId",
|
|
30
|
-
"requestId",
|
|
31
|
-
"type",
|
|
32
|
-
"payload",
|
|
33
|
-
].includes(k)))
|
|
34
|
-
throw Error("UNKNOWN_FIELD");
|
|
35
|
-
if (m.protocolVersion !== 1)
|
|
36
|
-
throw Error("UNSUPPORTED_VERSION");
|
|
37
|
-
for (const k of ["messageId", "requestId"])
|
|
38
|
-
if (typeof m[k] !== "string" ||
|
|
39
|
-
!/^[a-zA-Z0-9_.:-]{1,128}$/.test(m[k]))
|
|
40
|
-
throw Error("INVALID_ID");
|
|
41
|
-
if (!["request", "status", "result", "cancel"].includes(m.type))
|
|
42
|
-
throw Error("UNSUPPORTED_TYPE");
|
|
43
|
-
object(m.payload);
|
|
44
|
-
if (Buffer.byteLength(JSON.stringify(m)) > 16384)
|
|
45
|
-
throw Error("MESSAGE_TOO_LARGE");
|
|
46
|
-
return structuredClone(m);
|
|
47
|
-
}
|
|
48
|
-
function freezeJson(value) {
|
|
49
|
-
if (value && typeof value === "object") {
|
|
50
|
-
for (const child of Object.values(value))
|
|
51
|
-
freezeJson(child);
|
|
52
|
-
Object.freeze(value);
|
|
53
|
-
}
|
|
54
|
-
return value;
|
|
3
|
+
import { Diagnostics } from "./diagnostics.js";
|
|
4
|
+
import { ERRORS } from "./error-codes.js";
|
|
5
|
+
import { assertJson } from "./json.js";
|
|
6
|
+
import { LIMITS } from "./limits.js";
|
|
7
|
+
import { CORE_DEFAULTS } from "./product.js";
|
|
8
|
+
import { canonical, freezeJson, object, parse } from "./protocol.js";
|
|
9
|
+
export { object, parse };
|
|
10
|
+
const isTerminal = (s) => !["queued", "running"].includes(s);
|
|
11
|
+
/** 对 payload 做规范排序后的 SHA-256 摘要,仅用于同 requestId 的冲突比较。 */
|
|
12
|
+
function hashPayload(payload) {
|
|
13
|
+
return createHash("sha256").update(canonical(payload)).digest("hex");
|
|
55
14
|
}
|
|
56
|
-
const terminal = (s) => !["queued", "running"].includes(s);
|
|
57
15
|
/** Volatile single-process bus; no acceptance durability or automatic execution retry. */
|
|
58
16
|
export class ExecutionBus {
|
|
59
17
|
options;
|
|
@@ -78,13 +36,7 @@ export class ExecutionBus {
|
|
|
78
36
|
}
|
|
79
37
|
closing;
|
|
80
38
|
shutdownPhase = "open";
|
|
81
|
-
constructor(options = {
|
|
82
|
-
concurrency: 2,
|
|
83
|
-
queueLimit: 32,
|
|
84
|
-
recordLimit: 256,
|
|
85
|
-
resultBytes: 65536,
|
|
86
|
-
retentionMs: 3600000,
|
|
87
|
-
}, admission = {}) {
|
|
39
|
+
constructor(options = CORE_DEFAULTS, admission = {}) {
|
|
88
40
|
this.options = options;
|
|
89
41
|
this.admission = admission;
|
|
90
42
|
const allowed = ["sessionQueueLimit", "maxQueueWaitMs"];
|
|
@@ -92,7 +44,7 @@ export class ExecutionBus {
|
|
|
92
44
|
typeof admission !== "object" ||
|
|
93
45
|
Array.isArray(admission) ||
|
|
94
46
|
Reflect.ownKeys(admission).some((key) => typeof key !== "string" || !allowed.includes(key)))
|
|
95
|
-
throw Error(
|
|
47
|
+
throw Error(ERRORS.INVALID_ADMISSION_POLICY);
|
|
96
48
|
for (const key of Reflect.ownKeys(admission)) {
|
|
97
49
|
const descriptor = Object.getOwnPropertyDescriptor(admission, key);
|
|
98
50
|
if (!("value" in descriptor) ||
|
|
@@ -100,7 +52,7 @@ export class ExecutionBus {
|
|
|
100
52
|
!Number.isSafeInteger(descriptor.value) ||
|
|
101
53
|
descriptor.value < 1 ||
|
|
102
54
|
descriptor.value > 2147483647)
|
|
103
|
-
throw Error(
|
|
55
|
+
throw Error(ERRORS.INVALID_ADMISSION_POLICY);
|
|
104
56
|
}
|
|
105
57
|
this.admission = Object.freeze({ ...admission });
|
|
106
58
|
const keys = [
|
|
@@ -118,15 +70,15 @@ export class ExecutionBus {
|
|
|
118
70
|
const descriptor = Object.getOwnPropertyDescriptor(options, key);
|
|
119
71
|
return (!descriptor || !("value" in descriptor) || !descriptor.enumerable);
|
|
120
72
|
}))
|
|
121
|
-
throw Error(
|
|
73
|
+
throw Error(ERRORS.INVALID_OPTIONS);
|
|
122
74
|
this.options = Object.freeze({ ...options });
|
|
123
75
|
for (const v of Object.values(this.options))
|
|
124
76
|
if (!Number.isSafeInteger(v) || v < 1)
|
|
125
|
-
throw Error(
|
|
77
|
+
throw Error(ERRORS.INVALID_OPTIONS);
|
|
126
78
|
}
|
|
127
79
|
register(name, handler) {
|
|
128
80
|
if (this.handlers.has(name))
|
|
129
|
-
throw Error(
|
|
81
|
+
throw Error(ERRORS.DUPLICATE_HANDLER);
|
|
130
82
|
const registration = { handler, token: Symbol(name) };
|
|
131
83
|
this.handlers.set(name, registration);
|
|
132
84
|
let disposed = false;
|
|
@@ -140,7 +92,7 @@ export class ExecutionBus {
|
|
|
140
92
|
}
|
|
141
93
|
key(o, id) {
|
|
142
94
|
if (!o.sessionId || !o.callId)
|
|
143
|
-
throw Error(
|
|
95
|
+
throw Error(ERRORS.UNTRUSTED_CONTEXT);
|
|
144
96
|
return JSON.stringify([o.sessionId, id]);
|
|
145
97
|
}
|
|
146
98
|
emit(e, type, payload, correlationId = "") {
|
|
@@ -154,8 +106,7 @@ export class ExecutionBus {
|
|
|
154
106
|
type,
|
|
155
107
|
payload,
|
|
156
108
|
});
|
|
157
|
-
//
|
|
158
|
-
// overtake the current event for later observers. Removed listeners are skipped.
|
|
109
|
+
// 在调用用户代码前快照并排队:嵌套取消无法抢到当前事件。
|
|
159
110
|
e.notifications.push({ event, listeners: [...e.listeners] });
|
|
160
111
|
if (e.dispatching)
|
|
161
112
|
return;
|
|
@@ -175,7 +126,7 @@ export class ExecutionBus {
|
|
|
175
126
|
}
|
|
176
127
|
finally {
|
|
177
128
|
e.dispatching = false;
|
|
178
|
-
if (
|
|
129
|
+
if (isTerminal(e.view.state))
|
|
179
130
|
e.listeners.clear();
|
|
180
131
|
}
|
|
181
132
|
}
|
|
@@ -185,68 +136,87 @@ export class ExecutionBus {
|
|
|
185
136
|
get(o, id) {
|
|
186
137
|
const e = this.entries.get(this.key(o, id));
|
|
187
138
|
if (!e)
|
|
188
|
-
throw Error(
|
|
139
|
+
throw Error(ERRORS.NOT_FOUND);
|
|
189
140
|
return e;
|
|
190
141
|
}
|
|
191
142
|
sweep(now = Date.now()) {
|
|
192
143
|
for (const [k, e] of this.entries)
|
|
193
|
-
if (
|
|
194
|
-
now - e.created > this.options.retentionMs) {
|
|
144
|
+
if (isTerminal(e.view.state) && now - e.created > this.options.retentionMs) {
|
|
195
145
|
this.retainedResultBytes -= e.resultBytes;
|
|
196
146
|
this.entries.delete(k);
|
|
197
147
|
}
|
|
198
148
|
}
|
|
199
149
|
submit(owner, raw, onEvent, invoke) {
|
|
200
150
|
if (this.closed)
|
|
201
|
-
throw Error(
|
|
151
|
+
throw Error(ERRORS.CLOSED);
|
|
202
152
|
const m = parse(raw);
|
|
203
153
|
if (m.type !== "request")
|
|
204
|
-
throw Error(
|
|
154
|
+
throw Error(ERRORS.EXPECTED_REQUEST);
|
|
155
|
+
const parts = this.requestParts(m);
|
|
156
|
+
const key = this.key(owner, m.requestId);
|
|
157
|
+
const fingerprint = hashPayload(parts.payload);
|
|
158
|
+
this.sweep();
|
|
159
|
+
const existing = this.entries.get(key);
|
|
160
|
+
return existing
|
|
161
|
+
? this.reuseInvocation(existing, m, fingerprint, onEvent)
|
|
162
|
+
: this.admitInvocation(owner, m, parts, key, fingerprint, invoke, onEvent);
|
|
163
|
+
}
|
|
164
|
+
/** 校验并拆解 request 的 payload,返回 handler 名、入参与原文。 */
|
|
165
|
+
requestParts(m) {
|
|
205
166
|
const p = m.payload;
|
|
206
167
|
if (Object.keys(p).some((k) => !["handler", "input"].includes(k)) ||
|
|
207
168
|
typeof p.handler !== "string")
|
|
208
|
-
throw Error(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
169
|
+
throw Error(ERRORS.INVALID_REQUEST);
|
|
170
|
+
return {
|
|
171
|
+
handler: p.handler,
|
|
172
|
+
input: object(p.input),
|
|
173
|
+
payload: p,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** 同一 requestId 的重复提交:校验一致性后复用执行或结果。 */
|
|
177
|
+
reuseInvocation(e, m, fingerprint, onEvent) {
|
|
178
|
+
if (e.fingerprint !== fingerprint)
|
|
179
|
+
throw Error(ERRORS.REQUEST_CONFLICT);
|
|
180
|
+
// 仅显式声明非幂等(idempotent:false)的 handler,终态结果才不可复用:
|
|
181
|
+
// 返回"已执行"会掩盖副作用状态。运行中的重复 attach 仍放行,不会二次执行。
|
|
182
|
+
if (e.handler.idempotent === false && isTerminal(e.view.state))
|
|
183
|
+
throw Error(ERRORS.MUTATING_REUSE_DENIED);
|
|
184
|
+
const previous = e.messages.get(m.messageId);
|
|
185
|
+
if (previous && previous !== canonical(m))
|
|
186
|
+
throw Error(ERRORS.MESSAGE_CONFLICT);
|
|
187
|
+
if (!previous && e.messages.size >= LIMITS.messageAccountLimit)
|
|
188
|
+
throw Error(ERRORS.MESSAGE_LIMIT);
|
|
189
|
+
// 先取得附着,再提交消息身份:附着被拒(WAITER_LIMIT)时无副作用。
|
|
190
|
+
const attached = this.attachment(e, onEvent, m.messageId);
|
|
191
|
+
if (!previous)
|
|
192
|
+
e.messages.set(m.messageId, canonical(m));
|
|
193
|
+
this.telemetry.record(e.trace, "reused");
|
|
194
|
+
return attached;
|
|
195
|
+
}
|
|
196
|
+
/** 受理全新请求:校验 Handler、检查容量、构建记录并入队。 */
|
|
197
|
+
admitInvocation(owner, m, parts, key, fingerprint, invoke, onEvent) {
|
|
198
|
+
const handler = this.handlers.get(parts.handler)?.handler;
|
|
229
199
|
if (!handler)
|
|
230
|
-
throw Error(
|
|
231
|
-
handler.validate(input);
|
|
200
|
+
throw Error(ERRORS.UNKNOWN_HANDLER);
|
|
201
|
+
handler.validate(parts.input);
|
|
232
202
|
if (this.entries.size >= this.options.recordLimit ||
|
|
233
203
|
this.queue.length >= this.options.queueLimit)
|
|
234
|
-
throw Error(
|
|
204
|
+
throw Error(ERRORS.BACKPRESSURE);
|
|
235
205
|
if (this.admission.sessionQueueLimit !== undefined &&
|
|
236
206
|
this.queue.filter((entry) => entry.owner === owner.sessionId).length >=
|
|
237
207
|
this.admission.sessionQueueLimit)
|
|
238
|
-
throw Error(
|
|
208
|
+
throw Error(ERRORS.SESSION_BACKPRESSURE);
|
|
239
209
|
let resolve;
|
|
240
210
|
const done = new Promise((r) => (resolve = r));
|
|
241
|
-
e = {
|
|
211
|
+
const e = {
|
|
242
212
|
trace: this.telemetry.begin(),
|
|
243
213
|
resultBytes: 0,
|
|
244
214
|
invoke,
|
|
245
215
|
correlationId: m.messageId,
|
|
246
216
|
owner: owner.sessionId,
|
|
247
|
-
fingerprint
|
|
217
|
+
fingerprint,
|
|
248
218
|
handler,
|
|
249
|
-
input: structuredClone(input),
|
|
219
|
+
input: structuredClone(parts.input),
|
|
250
220
|
view: {
|
|
251
221
|
requestId: m.requestId,
|
|
252
222
|
state: "queued",
|
|
@@ -268,33 +238,36 @@ export class ExecutionBus {
|
|
|
268
238
|
this.telemetry.record(e.trace, "accepted");
|
|
269
239
|
const attached = this.attachment(e, onEvent, m.messageId);
|
|
270
240
|
this.queue.push(e);
|
|
271
|
-
|
|
272
|
-
e.deadline = performance.now() + this.admission.maxQueueWaitMs;
|
|
273
|
-
const entry = e;
|
|
274
|
-
const expire = () => {
|
|
275
|
-
if (entry.view.state !== "queued")
|
|
276
|
-
return;
|
|
277
|
-
const remaining = entry.deadline - performance.now();
|
|
278
|
-
if (remaining > 0) {
|
|
279
|
-
entry.queueTimer = setTimeout(expire, Math.ceil(remaining));
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
this.removeQueued(entry);
|
|
283
|
-
this.finish(entry, "failed", undefined, "QUEUE_WAIT_TIMEOUT");
|
|
284
|
-
};
|
|
285
|
-
e.queueTimer = setTimeout(expire, this.admission.maxQueueWaitMs);
|
|
286
|
-
}
|
|
241
|
+
this.scheduleQueueDeadline(e);
|
|
287
242
|
if (!this.readyOwners.includes(e.owner))
|
|
288
243
|
this.readyOwners.push(e.owner);
|
|
289
244
|
queueMicrotask(() => this.pump());
|
|
290
245
|
return attached;
|
|
291
246
|
}
|
|
247
|
+
/** 若启用了排队期限,登记到期结算为 QUEUE_WAIT_TIMEOUT 的计时器。 */
|
|
248
|
+
scheduleQueueDeadline(e) {
|
|
249
|
+
if (this.admission.maxQueueWaitMs === undefined)
|
|
250
|
+
return;
|
|
251
|
+
e.deadline = performance.now() + this.admission.maxQueueWaitMs;
|
|
252
|
+
const expire = () => {
|
|
253
|
+
if (e.view.state !== "queued")
|
|
254
|
+
return;
|
|
255
|
+
const remaining = e.deadline - performance.now();
|
|
256
|
+
if (remaining > 0) {
|
|
257
|
+
e.queueTimer = setTimeout(expire, Math.ceil(remaining));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
this.removeQueued(e);
|
|
261
|
+
this.finish(e, "failed", undefined, "QUEUE_WAIT_TIMEOUT");
|
|
262
|
+
};
|
|
263
|
+
e.queueTimer = setTimeout(expire, this.admission.maxQueueWaitMs);
|
|
264
|
+
}
|
|
292
265
|
attachment(e, onEvent, id) {
|
|
293
|
-
//
|
|
266
|
+
// 每次附着独立占配额,即使调用方复用同一个回调。
|
|
294
267
|
const listener = onEvent ? (event) => onEvent(event) : undefined;
|
|
295
|
-
if (listener && !
|
|
296
|
-
if (e.listeners.size >=
|
|
297
|
-
throw Error(
|
|
268
|
+
if (listener && !isTerminal(e.view.state)) {
|
|
269
|
+
if (e.listeners.size >= LIMITS.waiterLimit)
|
|
270
|
+
throw Error(ERRORS.WAITER_LIMIT);
|
|
298
271
|
e.listeners.add(listener);
|
|
299
272
|
}
|
|
300
273
|
const accepted = {
|
|
@@ -324,20 +297,20 @@ export class ExecutionBus {
|
|
|
324
297
|
command(owner, raw) {
|
|
325
298
|
const m = parse(raw);
|
|
326
299
|
if (m.type === "request")
|
|
327
|
-
throw Error(
|
|
300
|
+
throw Error(ERRORS.EXPECTED_CONTROL);
|
|
328
301
|
if (Object.keys(m.payload).length)
|
|
329
|
-
throw Error(
|
|
302
|
+
throw Error(ERRORS.INVALID_CONTROL);
|
|
330
303
|
this.sweep();
|
|
331
304
|
const e = this.get(owner, m.requestId);
|
|
332
305
|
const previous = e.messages.get(m.messageId), fingerprint = canonical(m);
|
|
333
306
|
if (previous && previous !== fingerprint)
|
|
334
|
-
throw Error(
|
|
307
|
+
throw Error(ERRORS.MESSAGE_CONFLICT);
|
|
335
308
|
if (!previous) {
|
|
336
|
-
//
|
|
337
|
-
if (e.messages.size >=
|
|
309
|
+
// 保留一个应急控制槽:读流量绝不能阻塞取消。
|
|
310
|
+
if (e.messages.size >= LIMITS.messageAccountLimit) {
|
|
338
311
|
if (m.type !== "cancel")
|
|
339
|
-
throw Error(
|
|
340
|
-
if (e.view.cancelRequested ||
|
|
312
|
+
throw Error(ERRORS.MESSAGE_LIMIT);
|
|
313
|
+
if (e.view.cancelRequested || isTerminal(e.view.state))
|
|
341
314
|
return this.view(e);
|
|
342
315
|
}
|
|
343
316
|
e.messages.set(m.messageId, fingerprint);
|
|
@@ -354,14 +327,14 @@ export class ExecutionBus {
|
|
|
354
327
|
/** Trusted host owner required. Never exposes other sessions or business payloads. */
|
|
355
328
|
inspect(owner) {
|
|
356
329
|
if (!owner || typeof owner.sessionId !== "string" || !owner.sessionId)
|
|
357
|
-
throw Error(
|
|
330
|
+
throw Error(ERRORS.OWNING_SESSION_REQUIRED);
|
|
358
331
|
this.sweep();
|
|
359
332
|
const now = performance.now();
|
|
360
333
|
const records = [...this.entries.values()].filter((e) => e.owner === owner.sessionId);
|
|
361
|
-
//
|
|
362
|
-
const active = records.filter((e) => !
|
|
363
|
-
const history = records.filter((e) =>
|
|
364
|
-
const selected = [...active, ...history].slice(0,
|
|
334
|
+
// 活跃任务不得被更新的终态历史挤到列表之外。
|
|
335
|
+
const active = records.filter((e) => !isTerminal(e.view.state));
|
|
336
|
+
const history = records.filter((e) => isTerminal(e.view.state)).reverse();
|
|
337
|
+
const selected = [...active, ...history].slice(0, LIMITS.inspectTaskLimit);
|
|
365
338
|
const tasks = selected.map((e) => ({
|
|
366
339
|
requestId: e.view.requestId,
|
|
367
340
|
state: e.view.state,
|
|
@@ -374,7 +347,7 @@ export class ExecutionBus {
|
|
|
374
347
|
? "concurrency"
|
|
375
348
|
: "dispatch"
|
|
376
349
|
: null,
|
|
377
|
-
phase:
|
|
350
|
+
phase: isTerminal(e.view.state)
|
|
378
351
|
? e.view.delivery === "returned"
|
|
379
352
|
? "returned"
|
|
380
353
|
: "terminal"
|
|
@@ -395,14 +368,14 @@ export class ExecutionBus {
|
|
|
395
368
|
counts: {
|
|
396
369
|
queued: records.filter((e) => e.view.state === "queued").length,
|
|
397
370
|
running: records.filter((e) => e.view.state === "running").length,
|
|
398
|
-
terminal: records.filter((e) =>
|
|
371
|
+
terminal: records.filter((e) => isTerminal(e.view.state)).length,
|
|
399
372
|
},
|
|
400
373
|
tasks,
|
|
401
|
-
truncated: records.length >
|
|
374
|
+
truncated: records.length > LIMITS.inspectTaskLimit,
|
|
402
375
|
});
|
|
403
376
|
}
|
|
404
377
|
cancel(e) {
|
|
405
|
-
if (
|
|
378
|
+
if (isTerminal(e.view.state) || e.view.cancelRequested)
|
|
406
379
|
return;
|
|
407
380
|
e.view.cancelRequested = true;
|
|
408
381
|
this.telemetry.record(e.trace, "cancel_requested");
|
|
@@ -414,15 +387,16 @@ export class ExecutionBus {
|
|
|
414
387
|
}
|
|
415
388
|
}
|
|
416
389
|
finish(e, state, result, error, resultBytes = 0) {
|
|
417
|
-
if (
|
|
390
|
+
if (isTerminal(e.view.state))
|
|
418
391
|
return;
|
|
419
392
|
if (e.queueTimer !== undefined) {
|
|
420
393
|
clearTimeout(e.queueTimer);
|
|
421
394
|
e.queueTimer = undefined;
|
|
422
395
|
}
|
|
423
396
|
e.finishedAt = performance.now();
|
|
424
|
-
//
|
|
425
|
-
|
|
397
|
+
// 终态事件是下一个序号;先用它构建快照,避免把不可逆终态提交到视图之外。
|
|
398
|
+
const terminalSequence = e.view.sequence + 1;
|
|
399
|
+
let next = { ...e.view, state, sequence: terminalSequence };
|
|
426
400
|
if (result !== undefined)
|
|
427
401
|
next.result = result;
|
|
428
402
|
if (error)
|
|
@@ -437,7 +411,7 @@ export class ExecutionBus {
|
|
|
437
411
|
next = {
|
|
438
412
|
requestId: e.view.requestId,
|
|
439
413
|
state,
|
|
440
|
-
sequence:
|
|
414
|
+
sequence: terminalSequence,
|
|
441
415
|
cancelRequested: e.view.cancelRequested,
|
|
442
416
|
delivery: e.view.delivery,
|
|
443
417
|
error: "RESULT_NOT_RETAINED",
|
|
@@ -447,28 +421,36 @@ export class ExecutionBus {
|
|
|
447
421
|
e.resultBytes = resultBytes;
|
|
448
422
|
this.retainedResultBytes += resultBytes;
|
|
449
423
|
e.created = Date.now();
|
|
450
|
-
|
|
424
|
+
// emit() 会再自增序号;先整体回退一档,使终态事件恰好落在 terminalSequence,
|
|
425
|
+
// 与上面交付给等待者的快照序号完全一致。
|
|
426
|
+
e.view = { ...next, sequence: terminalSequence - 1 };
|
|
451
427
|
this.telemetry.record(e.trace, "terminal");
|
|
452
428
|
e.resolve(snapshot);
|
|
453
429
|
this.emit(e, state, { state });
|
|
454
|
-
//
|
|
430
|
+
// 重入的终态通知可能仍排在本批之后。
|
|
455
431
|
if (!e.dispatching)
|
|
456
432
|
e.listeners.clear();
|
|
457
433
|
}
|
|
434
|
+
/** 按会话轮转挑选下一个出队项:各会话内部 FIFO,跨会话公平。 */
|
|
435
|
+
nextQueuedIndex() {
|
|
436
|
+
let index = -1;
|
|
437
|
+
while (this.readyOwners.length && index < 0) {
|
|
438
|
+
const owner = this.readyOwners.shift();
|
|
439
|
+
const i = this.queue.findIndex((entry) => entry.owner === owner);
|
|
440
|
+
if (i >= 0) {
|
|
441
|
+
index = i;
|
|
442
|
+
// 找到后,若该会话仍有其它排队任务,保留其轮次避免单个会话饥饿。
|
|
443
|
+
if (this.queue.some((entry, j) => j !== i && entry.owner === owner))
|
|
444
|
+
this.readyOwners.push(owner);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return index < 0 ? 0 : index;
|
|
448
|
+
}
|
|
458
449
|
pump() {
|
|
459
450
|
while (!this.closed &&
|
|
460
451
|
this.running < this.options.concurrency &&
|
|
461
452
|
this.queue.length) {
|
|
462
|
-
|
|
463
|
-
while (this.readyOwners.length && index < 0) {
|
|
464
|
-
const owner = this.readyOwners.shift();
|
|
465
|
-
index = this.queue.findIndex((entry) => entry.owner === owner);
|
|
466
|
-
if (index >= 0 &&
|
|
467
|
-
this.queue.some((entry, i) => i !== index && entry.owner === owner))
|
|
468
|
-
this.readyOwners.push(owner);
|
|
469
|
-
}
|
|
470
|
-
if (index < 0)
|
|
471
|
-
index = 0;
|
|
453
|
+
const index = this.nextQueuedIndex();
|
|
472
454
|
const e = this.queue.splice(index, 1)[0];
|
|
473
455
|
if (e.view.state !== "queued")
|
|
474
456
|
continue;
|
package/dist/diagnostics.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { LIMITS } from "./limits.js";
|
|
2
3
|
/** In-memory numeric metadata only: never accepts request/session strings, input, output or error text. */
|
|
3
4
|
export class Diagnostics {
|
|
4
5
|
next = 0;
|
|
5
|
-
events = new Array(
|
|
6
|
+
events = new Array(LIMITS.traceRingSize);
|
|
6
7
|
cursor = 0;
|
|
7
8
|
size = 0;
|
|
8
9
|
counts = {};
|
|
@@ -16,14 +17,15 @@ export class Diagnostics {
|
|
|
16
17
|
stage,
|
|
17
18
|
elapsedMs: Math.max(0, performance.now() - context.at),
|
|
18
19
|
};
|
|
19
|
-
this.cursor = (this.cursor + 1) %
|
|
20
|
-
this.size = Math.min(this.size + 1,
|
|
20
|
+
this.cursor = (this.cursor + 1) % LIMITS.traceRingSize;
|
|
21
|
+
this.size = Math.min(this.size + 1, LIMITS.traceRingSize);
|
|
21
22
|
}
|
|
22
23
|
snapshot() {
|
|
23
24
|
return {
|
|
24
25
|
counts: { ...this.counts },
|
|
25
26
|
traces: Array.from({ length: this.size }, (_, index) => ({
|
|
26
|
-
...this.events[(this.cursor - this.size +
|
|
27
|
+
...this.events[(this.cursor - this.size + LIMITS.traceRingSize + index) %
|
|
28
|
+
LIMITS.traceRingSize],
|
|
27
29
|
})),
|
|
28
30
|
};
|
|
29
31
|
}
|