@hpplay-lebo/cluster-hub 2.0.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 +243 -0
- package/docs/CLI.md +279 -0
- package/openclaw.plugin.json +151 -0
- package/package.json +40 -0
- package/src/hub-client.ts +720 -0
- package/src/index.ts +1714 -0
- package/src/store.ts +337 -0
- package/src/types.ts +245 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub WebSocket + HTTP 客户端
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
import type {
|
|
7
|
+
HubPluginConfig,
|
|
8
|
+
HubNode,
|
|
9
|
+
HubCluster,
|
|
10
|
+
HubTreeNode,
|
|
11
|
+
RegisterRequest,
|
|
12
|
+
RegisterResponse,
|
|
13
|
+
WSMessage,
|
|
14
|
+
ResultPayload,
|
|
15
|
+
PendingTask,
|
|
16
|
+
InteractiveMessage,
|
|
17
|
+
} from './types.js';
|
|
18
|
+
|
|
19
|
+
type PluginLogger = {
|
|
20
|
+
debug?: (msg: string) => void;
|
|
21
|
+
info: (msg: string) => void;
|
|
22
|
+
warn: (msg: string) => void;
|
|
23
|
+
error: (msg: string) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export class HubClient {
|
|
27
|
+
private ws: any = null; // WebSocket instance
|
|
28
|
+
private config: HubPluginConfig;
|
|
29
|
+
private logger: PluginLogger;
|
|
30
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
31
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
32
|
+
private connected = false;
|
|
33
|
+
private intentionallyClosed = false;
|
|
34
|
+
|
|
35
|
+
// 缓存
|
|
36
|
+
private nodesCache: HubNode[] = [];
|
|
37
|
+
private nodesCacheTime = 0;
|
|
38
|
+
private readonly CACHE_TTL_MS = 15_000;
|
|
39
|
+
|
|
40
|
+
// 变更序号(节点上下线、注册等事件递增,供控制台判断是否需要全量刷新)
|
|
41
|
+
private _changeSeq = 0;
|
|
42
|
+
get changeSeq(): number { return this._changeSeq; }
|
|
43
|
+
|
|
44
|
+
// 任务追踪
|
|
45
|
+
private pendingTasks: Map<string, PendingTask> = new Map();
|
|
46
|
+
// 指令交互消息(nodeId → messages)
|
|
47
|
+
private nodeMessages: Map<string, InteractiveMessage[]> = new Map();
|
|
48
|
+
|
|
49
|
+
// 事件回调
|
|
50
|
+
public onTaskReceived?: (task: WSMessage) => void;
|
|
51
|
+
public onNodeOnline?: (nodeId: string) => void;
|
|
52
|
+
public onNodeOffline?: (nodeId: string) => void;
|
|
53
|
+
public onConnected?: () => void;
|
|
54
|
+
public onDisconnected?: () => void;
|
|
55
|
+
|
|
56
|
+
// 通用事件监听
|
|
57
|
+
private eventListeners: Map<string, Array<(msg: WSMessage) => void>> = new Map();
|
|
58
|
+
|
|
59
|
+
on(event: string, handler: (msg: WSMessage) => void): void {
|
|
60
|
+
const listeners = this.eventListeners.get(event) || [];
|
|
61
|
+
listeners.push(handler);
|
|
62
|
+
this.eventListeners.set(event, listeners);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
off(event: string, handler: (msg: WSMessage) => void): void {
|
|
66
|
+
const listeners = this.eventListeners.get(event) || [];
|
|
67
|
+
this.eventListeners.set(event, listeners.filter(l => l !== handler));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private emit(event: string, msg: WSMessage): void {
|
|
71
|
+
const listeners = this.eventListeners.get(event) || [];
|
|
72
|
+
for (const handler of listeners) {
|
|
73
|
+
try { handler(msg); } catch (err: any) {
|
|
74
|
+
this.logger.error(`[cluster-hub] 事件处理错误 (${event}): ${err.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
constructor(config: HubPluginConfig, logger: PluginLogger) {
|
|
80
|
+
this.config = config;
|
|
81
|
+
this.logger = logger;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ========================================================================
|
|
85
|
+
// 配置
|
|
86
|
+
// ========================================================================
|
|
87
|
+
|
|
88
|
+
getConfig(): HubPluginConfig {
|
|
89
|
+
return { ...this.config };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
updateConfig(patch: Partial<HubPluginConfig>): void {
|
|
93
|
+
Object.assign(this.config, patch);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
isRegistered(): boolean {
|
|
97
|
+
return !!(this.config.nodeId && this.config.token);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
isConnected(): boolean {
|
|
101
|
+
return this.connected;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ========================================================================
|
|
105
|
+
// HTTP API
|
|
106
|
+
// ========================================================================
|
|
107
|
+
|
|
108
|
+
async httpGet(path: string): Promise<any> {
|
|
109
|
+
const url = `${this.config.hubUrl}${path}`;
|
|
110
|
+
const headers: Record<string, string> = {};
|
|
111
|
+
if (this.config.token) {
|
|
112
|
+
headers['Authorization'] = `Bearer ${this.config.token}`;
|
|
113
|
+
}
|
|
114
|
+
if (this.config.adminKey) {
|
|
115
|
+
headers['X-Admin-Key'] = this.config.adminKey;
|
|
116
|
+
}
|
|
117
|
+
const res = await fetch(url, { headers });
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
const body = await res.text();
|
|
120
|
+
throw new Error(`Hub HTTP ${res.status}: ${body}`);
|
|
121
|
+
}
|
|
122
|
+
return res.json();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async httpPost(path: string, body: any): Promise<any> {
|
|
126
|
+
const url = `${this.config.hubUrl}${path}`;
|
|
127
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
128
|
+
if (this.config.adminKey) {
|
|
129
|
+
headers['X-Admin-Key'] = this.config.adminKey;
|
|
130
|
+
}
|
|
131
|
+
if (this.config.token) {
|
|
132
|
+
headers['Authorization'] = `Bearer ${this.config.token}`;
|
|
133
|
+
}
|
|
134
|
+
const res = await fetch(url, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers,
|
|
137
|
+
body: JSON.stringify(body),
|
|
138
|
+
});
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
const text = await res.text();
|
|
141
|
+
throw new Error(`Hub HTTP ${res.status}: ${text}`);
|
|
142
|
+
}
|
|
143
|
+
return res.json();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async httpPatch(path: string, body: any): Promise<any> {
|
|
147
|
+
const url = `${this.config.hubUrl}${path}`;
|
|
148
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
149
|
+
if (this.config.token) {
|
|
150
|
+
headers['Authorization'] = `Bearer ${this.config.token}`;
|
|
151
|
+
}
|
|
152
|
+
if (this.config.adminKey) {
|
|
153
|
+
headers['X-Admin-Key'] = this.config.adminKey;
|
|
154
|
+
}
|
|
155
|
+
const res = await fetch(url, {
|
|
156
|
+
method: 'PATCH',
|
|
157
|
+
headers,
|
|
158
|
+
body: JSON.stringify(body),
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
const text = await res.text();
|
|
162
|
+
throw new Error(`Hub HTTP ${res.status}: ${text}`);
|
|
163
|
+
}
|
|
164
|
+
return res.json();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private async httpDelete(path: string): Promise<any> {
|
|
168
|
+
const url = `${this.config.hubUrl}${path}`;
|
|
169
|
+
const headers: Record<string, string> = {};
|
|
170
|
+
if (this.config.token) {
|
|
171
|
+
headers['Authorization'] = `Bearer ${this.config.token}`;
|
|
172
|
+
}
|
|
173
|
+
if (this.config.adminKey) {
|
|
174
|
+
headers['X-Admin-Key'] = this.config.adminKey;
|
|
175
|
+
}
|
|
176
|
+
const res = await fetch(url, { method: 'DELETE', headers });
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
const text = await res.text();
|
|
179
|
+
throw new Error(`Hub HTTP ${res.status}: ${text}`);
|
|
180
|
+
}
|
|
181
|
+
return res.json();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ========================================================================
|
|
185
|
+
// 节点注册
|
|
186
|
+
// ========================================================================
|
|
187
|
+
|
|
188
|
+
async register(req: RegisterRequest): Promise<RegisterResponse> {
|
|
189
|
+
const data = await this.httpPost('/api/nodes/register', req);
|
|
190
|
+
// Hub 返回 { success: true, data: { nodeId, clusterId, token, ... } }
|
|
191
|
+
const resp = data.data || data;
|
|
192
|
+
if (!data.success && !resp.nodeId) {
|
|
193
|
+
throw new Error(data.error || 'Registration failed');
|
|
194
|
+
}
|
|
195
|
+
const result: RegisterResponse = {
|
|
196
|
+
nodeId: resp.nodeId,
|
|
197
|
+
clusterId: resp.clusterId,
|
|
198
|
+
parentId: resp.parentId ?? null,
|
|
199
|
+
depth: resp.depth ?? 0,
|
|
200
|
+
token: resp.token,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// 更新本地配置
|
|
204
|
+
this.config.nodeId = result.nodeId;
|
|
205
|
+
this.config.clusterId = result.clusterId;
|
|
206
|
+
this.config.parentId = result.parentId;
|
|
207
|
+
this.config.token = result.token;
|
|
208
|
+
|
|
209
|
+
this.logger.info(`[cluster-hub] 注册成功: nodeId=${result.nodeId}, cluster=${result.clusterId}`);
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async registerChild(req: RegisterRequest): Promise<RegisterResponse> {
|
|
214
|
+
// 子节点注册(parentId 由调用者设置)
|
|
215
|
+
const data = await this.httpPost('/api/nodes/register', req);
|
|
216
|
+
const resp = data.data || data;
|
|
217
|
+
if (!data.success && !resp.nodeId) {
|
|
218
|
+
throw new Error(data.error || 'Child registration failed');
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
nodeId: resp.nodeId,
|
|
222
|
+
clusterId: resp.clusterId,
|
|
223
|
+
parentId: resp.parentId ?? null,
|
|
224
|
+
depth: resp.depth ?? 0,
|
|
225
|
+
token: resp.token,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async unregister(nodeId: string): Promise<void> {
|
|
230
|
+
await this.httpDelete(`/api/nodes/${nodeId}`);
|
|
231
|
+
if (nodeId === this.config.nodeId) {
|
|
232
|
+
this.config.nodeId = undefined;
|
|
233
|
+
this.config.token = undefined;
|
|
234
|
+
this.config.clusterId = undefined;
|
|
235
|
+
this.config.parentId = undefined;
|
|
236
|
+
this.disconnect();
|
|
237
|
+
}
|
|
238
|
+
this.logger.info(`[cluster-hub] 节点已注销: ${nodeId}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async reparent(nodeId: string, newParentId: string | null): Promise<any> {
|
|
242
|
+
const data = await this.httpPatch(`/api/nodes/${nodeId}/parent`, {
|
|
243
|
+
newParentId,
|
|
244
|
+
});
|
|
245
|
+
if (!data.success) {
|
|
246
|
+
throw new Error(data.error || 'Reparent failed');
|
|
247
|
+
}
|
|
248
|
+
const resp = data.data || data;
|
|
249
|
+
// 如果是自己的 reparent,更新 token
|
|
250
|
+
if (nodeId === this.config.nodeId && resp.token) {
|
|
251
|
+
this.config.token = resp.token;
|
|
252
|
+
this.config.parentId = newParentId;
|
|
253
|
+
this.config.clusterId = resp.clusterId;
|
|
254
|
+
}
|
|
255
|
+
return resp;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ========================================================================
|
|
259
|
+
// 节点查询
|
|
260
|
+
// ========================================================================
|
|
261
|
+
|
|
262
|
+
async fetchNodes(force = false): Promise<HubNode[]> {
|
|
263
|
+
const now = Date.now();
|
|
264
|
+
if (!force && this.nodesCache.length > 0 && (now - this.nodesCacheTime) < this.CACHE_TTL_MS) {
|
|
265
|
+
return this.nodesCache;
|
|
266
|
+
}
|
|
267
|
+
const data = await this.httpGet('/api/nodes');
|
|
268
|
+
this.nodesCache = data.nodes || [];
|
|
269
|
+
this.nodesCacheTime = now;
|
|
270
|
+
return this.nodesCache;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async fetchNode(nodeId: string): Promise<HubNode | null> {
|
|
274
|
+
try {
|
|
275
|
+
const data = await this.httpGet(`/api/nodes/${nodeId}`);
|
|
276
|
+
return data.data || data || null;
|
|
277
|
+
} catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async fetchChildren(nodeId: string): Promise<HubNode[]> {
|
|
283
|
+
const data = await this.httpGet(`/api/nodes/${nodeId}/children`);
|
|
284
|
+
return data.data || [];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async fetchTree(nodeId: string): Promise<HubTreeNode | null> {
|
|
288
|
+
try {
|
|
289
|
+
const data = await this.httpGet(`/api/nodes/${nodeId}/tree`);
|
|
290
|
+
return data.data || null;
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async fetchClusters(): Promise<HubCluster[]> {
|
|
297
|
+
const data = await this.httpGet('/api/clusters');
|
|
298
|
+
return data.data || [];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
getCachedNodes(): HubNode[] {
|
|
302
|
+
return [...this.nodesCache];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ========================================================================
|
|
306
|
+
// WebSocket 连接
|
|
307
|
+
// ========================================================================
|
|
308
|
+
|
|
309
|
+
async connect(): Promise<void> {
|
|
310
|
+
if (!this.config.token) {
|
|
311
|
+
this.logger.warn('[cluster-hub] 无 Token,无法连接 WebSocket');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (this.connected) {
|
|
315
|
+
this.logger.debug?.('[cluster-hub] 已连接,跳过');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this.intentionallyClosed = false;
|
|
320
|
+
|
|
321
|
+
const wsUrl = this.config.hubUrl
|
|
322
|
+
.replace(/^https:/, 'wss:')
|
|
323
|
+
.replace(/^http:/, 'ws:')
|
|
324
|
+
+ `/ws?token=${encodeURIComponent(this.config.token)}`;
|
|
325
|
+
|
|
326
|
+
this.logger.info(`[cluster-hub] 连接 WebSocket: ${this.config.hubUrl}`);
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
// 使用原生 WebSocket(Node.js 22+ 内置)
|
|
330
|
+
const WebSocketImpl = typeof WebSocket !== 'undefined'
|
|
331
|
+
? WebSocket
|
|
332
|
+
: (await import('ws')).default;
|
|
333
|
+
|
|
334
|
+
this.ws = new WebSocketImpl(wsUrl);
|
|
335
|
+
|
|
336
|
+
this.ws.onopen = () => {
|
|
337
|
+
this.connected = true;
|
|
338
|
+
this.logger.info('[cluster-hub] WebSocket 已连接');
|
|
339
|
+
this.startHeartbeat();
|
|
340
|
+
this.onConnected?.();
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
this.ws.onmessage = (event: any) => {
|
|
344
|
+
try {
|
|
345
|
+
const data = typeof event.data === 'string' ? event.data : event.data.toString();
|
|
346
|
+
const msg: WSMessage = JSON.parse(data);
|
|
347
|
+
this.handleMessage(msg);
|
|
348
|
+
} catch (err: any) {
|
|
349
|
+
this.logger.error(`[cluster-hub] 解析消息失败: ${err.message}`);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
this.ws.onclose = () => {
|
|
354
|
+
this.connected = false;
|
|
355
|
+
this.stopHeartbeat();
|
|
356
|
+
this.logger.info('[cluster-hub] WebSocket 断开');
|
|
357
|
+
this.onDisconnected?.();
|
|
358
|
+
|
|
359
|
+
if (!this.intentionallyClosed) {
|
|
360
|
+
this.scheduleReconnect();
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
this.ws.onerror = (err: any) => {
|
|
365
|
+
this.logger.error(`[cluster-hub] WebSocket 错误: ${err.message || err}`);
|
|
366
|
+
};
|
|
367
|
+
} catch (err: any) {
|
|
368
|
+
this.logger.error(`[cluster-hub] 连接失败: ${err.message}`);
|
|
369
|
+
this.scheduleReconnect();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
disconnect(): void {
|
|
374
|
+
this.intentionallyClosed = true;
|
|
375
|
+
this.stopHeartbeat();
|
|
376
|
+
if (this.reconnectTimer) {
|
|
377
|
+
clearTimeout(this.reconnectTimer);
|
|
378
|
+
this.reconnectTimer = null;
|
|
379
|
+
}
|
|
380
|
+
if (this.ws) {
|
|
381
|
+
this.ws.close();
|
|
382
|
+
this.ws = null;
|
|
383
|
+
}
|
|
384
|
+
this.connected = false;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private scheduleReconnect(): void {
|
|
388
|
+
if (this.intentionallyClosed) return;
|
|
389
|
+
if (this.reconnectTimer) return;
|
|
390
|
+
|
|
391
|
+
const ms = this.config.reconnectIntervalMs;
|
|
392
|
+
this.logger.info(`[cluster-hub] ${ms}ms 后重连...`);
|
|
393
|
+
this.reconnectTimer = setTimeout(() => {
|
|
394
|
+
this.reconnectTimer = null;
|
|
395
|
+
this.connect();
|
|
396
|
+
}, ms);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ========================================================================
|
|
400
|
+
// 心跳
|
|
401
|
+
// ========================================================================
|
|
402
|
+
|
|
403
|
+
private startHeartbeat(): void {
|
|
404
|
+
this.stopHeartbeat();
|
|
405
|
+
this.heartbeatTimer = setInterval(() => {
|
|
406
|
+
this.sendHeartbeat();
|
|
407
|
+
}, this.config.heartbeatIntervalMs);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private stopHeartbeat(): void {
|
|
411
|
+
if (this.heartbeatTimer) {
|
|
412
|
+
clearInterval(this.heartbeatTimer);
|
|
413
|
+
this.heartbeatTimer = null;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private sendHeartbeat(): void {
|
|
418
|
+
this.sendWS({
|
|
419
|
+
type: 'heartbeat',
|
|
420
|
+
id: randomUUID(),
|
|
421
|
+
payload: {
|
|
422
|
+
load: 0, // TODO: 获取实际负载
|
|
423
|
+
activeTasks: this.pendingTasks.size,
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ========================================================================
|
|
429
|
+
// 消息收发
|
|
430
|
+
// ========================================================================
|
|
431
|
+
|
|
432
|
+
sendWS(msg: WSMessage): void {
|
|
433
|
+
if (!this.ws || !this.connected) {
|
|
434
|
+
this.logger.warn('[cluster-hub] WebSocket 未连接,无法发送');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
this.ws.send(JSON.stringify(msg));
|
|
439
|
+
} catch (err: any) {
|
|
440
|
+
this.logger.error(`[cluster-hub] 发送失败: ${err.message}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private handleMessage(msg: WSMessage): void {
|
|
445
|
+
switch (msg.type) {
|
|
446
|
+
case 'task':
|
|
447
|
+
this.handleIncomingTask(msg);
|
|
448
|
+
break;
|
|
449
|
+
case 'result':
|
|
450
|
+
this.handleResult(msg);
|
|
451
|
+
break;
|
|
452
|
+
case 'task_ack':
|
|
453
|
+
this.emit('task_ack', msg);
|
|
454
|
+
break;
|
|
455
|
+
case 'task_status':
|
|
456
|
+
this.emit('task_status', msg);
|
|
457
|
+
break;
|
|
458
|
+
case 'task_cancel':
|
|
459
|
+
this.emit('task_cancel', msg);
|
|
460
|
+
break;
|
|
461
|
+
case 'chat':
|
|
462
|
+
this.emit('chat', msg);
|
|
463
|
+
break;
|
|
464
|
+
case 'direct':
|
|
465
|
+
this.handleDirect(msg);
|
|
466
|
+
break;
|
|
467
|
+
case 'broadcast':
|
|
468
|
+
this.handleBroadcast(msg);
|
|
469
|
+
break;
|
|
470
|
+
case 'heartbeat':
|
|
471
|
+
// 心跳确认,忽略
|
|
472
|
+
break;
|
|
473
|
+
default:
|
|
474
|
+
this.logger.debug?.(`[cluster-hub] 未知消息类型: ${msg.type}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private handleIncomingTask(msg: WSMessage): void {
|
|
479
|
+
this.logger.info(`[cluster-hub] 收到任务: ${msg.id} from ${msg.from}`);
|
|
480
|
+
this.onTaskReceived?.(msg);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private handleResult(msg: WSMessage): void {
|
|
484
|
+
const taskId = msg.id;
|
|
485
|
+
|
|
486
|
+
// 通知 TaskTracker(v2 异步模式)
|
|
487
|
+
this.emit('result', msg);
|
|
488
|
+
|
|
489
|
+
// 兼容旧的同步等待模式(pendingTasks)
|
|
490
|
+
const pending = this.pendingTasks.get(taskId);
|
|
491
|
+
if (!pending) {
|
|
492
|
+
this.logger.debug?.(`[cluster-hub] 收到任务结果: ${taskId} (异步模式)`);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
clearTimeout(pending.timer);
|
|
497
|
+
this.pendingTasks.delete(taskId);
|
|
498
|
+
|
|
499
|
+
const result: ResultPayload = msg.payload || {};
|
|
500
|
+
this.logger.info(`[cluster-hub] 任务完成: ${taskId}, success=${result.success}`);
|
|
501
|
+
|
|
502
|
+
// 更新消息状态
|
|
503
|
+
const messages = this.nodeMessages.get(pending.nodeId) || [];
|
|
504
|
+
const assistantMsg: InteractiveMessage = {
|
|
505
|
+
id: randomUUID(),
|
|
506
|
+
role: 'assistant',
|
|
507
|
+
content: result.success ? (result.result || '(无返回内容)') : (result.error || '任务失败'),
|
|
508
|
+
timestamp: Date.now(),
|
|
509
|
+
status: result.success ? 'completed' : 'failed',
|
|
510
|
+
taskId,
|
|
511
|
+
};
|
|
512
|
+
messages.push(assistantMsg);
|
|
513
|
+
this.nodeMessages.set(pending.nodeId, messages);
|
|
514
|
+
|
|
515
|
+
pending.resolve(result);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private handleDirect(msg: WSMessage): void {
|
|
519
|
+
if (msg.payload?.action === 'connected') {
|
|
520
|
+
this.logger.info(`[cluster-hub] 连接确认: nodeId=${msg.payload.nodeId}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private handleBroadcast(msg: WSMessage): void {
|
|
525
|
+
if (msg.channel === 'system') {
|
|
526
|
+
const action = msg.payload?.action;
|
|
527
|
+
if (action === 'node_online') {
|
|
528
|
+
const nodeId = msg.payload?.node?.id;
|
|
529
|
+
if (nodeId) {
|
|
530
|
+
this.logger.info(`[cluster-hub] 节点上线: ${nodeId}`);
|
|
531
|
+
this.nodesCache = [];
|
|
532
|
+
this._changeSeq++;
|
|
533
|
+
this.onNodeOnline?.(nodeId);
|
|
534
|
+
}
|
|
535
|
+
} else if (action === 'node_offline') {
|
|
536
|
+
const nodeId = msg.payload?.nodeId;
|
|
537
|
+
if (nodeId) {
|
|
538
|
+
this.logger.info(`[cluster-hub] 节点离线: ${nodeId}`);
|
|
539
|
+
this.nodesCache = [];
|
|
540
|
+
this._changeSeq++;
|
|
541
|
+
this.onNodeOffline?.(nodeId);
|
|
542
|
+
}
|
|
543
|
+
} else if (['child_registered', 'child_unregistered', 'child_departed', 'child_arrived', 'reparented'].includes(action)) {
|
|
544
|
+
this.logger.info(`[cluster-hub] 集群变更: ${action}`);
|
|
545
|
+
this.nodesCache = [];
|
|
546
|
+
this._changeSeq++;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ========================================================================
|
|
552
|
+
// 任务发送
|
|
553
|
+
// ========================================================================
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* 给指定节点发送指令,返回 Promise 等待结果
|
|
557
|
+
*/
|
|
558
|
+
sendTask(nodeId: string, instruction: string, timeoutMs?: number): Promise<ResultPayload> {
|
|
559
|
+
const taskId = randomUUID();
|
|
560
|
+
const timeout = timeoutMs || this.config.taskTimeoutMs;
|
|
561
|
+
|
|
562
|
+
// 记录用户消息
|
|
563
|
+
const messages = this.nodeMessages.get(nodeId) || [];
|
|
564
|
+
messages.push({
|
|
565
|
+
id: randomUUID(),
|
|
566
|
+
role: 'user',
|
|
567
|
+
content: instruction,
|
|
568
|
+
timestamp: Date.now(),
|
|
569
|
+
status: 'sending',
|
|
570
|
+
taskId,
|
|
571
|
+
});
|
|
572
|
+
this.nodeMessages.set(nodeId, messages);
|
|
573
|
+
|
|
574
|
+
return new Promise<ResultPayload>((resolve, reject) => {
|
|
575
|
+
const timer = setTimeout(() => {
|
|
576
|
+
this.pendingTasks.delete(taskId);
|
|
577
|
+
// 更新消息状态
|
|
578
|
+
const msgs = this.nodeMessages.get(nodeId) || [];
|
|
579
|
+
msgs.push({
|
|
580
|
+
id: randomUUID(),
|
|
581
|
+
role: 'assistant',
|
|
582
|
+
content: '任务超时',
|
|
583
|
+
timestamp: Date.now(),
|
|
584
|
+
status: 'timeout',
|
|
585
|
+
taskId,
|
|
586
|
+
});
|
|
587
|
+
this.nodeMessages.set(nodeId, msgs);
|
|
588
|
+
reject(new Error(`Task ${taskId} timed out after ${timeout}ms`));
|
|
589
|
+
}, timeout);
|
|
590
|
+
|
|
591
|
+
this.pendingTasks.set(taskId, {
|
|
592
|
+
taskId,
|
|
593
|
+
nodeId,
|
|
594
|
+
instruction,
|
|
595
|
+
createdAt: Date.now(),
|
|
596
|
+
timeoutMs: timeout,
|
|
597
|
+
resolve,
|
|
598
|
+
reject,
|
|
599
|
+
timer,
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
this.sendWS({
|
|
603
|
+
type: 'task',
|
|
604
|
+
id: taskId,
|
|
605
|
+
to: nodeId,
|
|
606
|
+
payload: {
|
|
607
|
+
task: instruction,
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// 更新发送状态
|
|
612
|
+
const last = messages[messages.length - 1];
|
|
613
|
+
if (last) last.status = 'running';
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* 给指定节点发送指令(fire-and-forget,不等结果),返回 taskId
|
|
619
|
+
*/
|
|
620
|
+
sendTaskAsync(nodeId: string, instruction: string): string {
|
|
621
|
+
const taskId = randomUUID();
|
|
622
|
+
|
|
623
|
+
const messages = this.nodeMessages.get(nodeId) || [];
|
|
624
|
+
messages.push({
|
|
625
|
+
id: randomUUID(),
|
|
626
|
+
role: 'user',
|
|
627
|
+
content: instruction,
|
|
628
|
+
timestamp: Date.now(),
|
|
629
|
+
status: 'running',
|
|
630
|
+
taskId,
|
|
631
|
+
});
|
|
632
|
+
this.nodeMessages.set(nodeId, messages);
|
|
633
|
+
|
|
634
|
+
// 注册等待(超时后自动清理)
|
|
635
|
+
const timer = setTimeout(() => {
|
|
636
|
+
this.pendingTasks.delete(taskId);
|
|
637
|
+
}, this.config.taskTimeoutMs);
|
|
638
|
+
|
|
639
|
+
this.pendingTasks.set(taskId, {
|
|
640
|
+
taskId,
|
|
641
|
+
nodeId,
|
|
642
|
+
instruction,
|
|
643
|
+
createdAt: Date.now(),
|
|
644
|
+
timeoutMs: this.config.taskTimeoutMs,
|
|
645
|
+
resolve: () => {},
|
|
646
|
+
reject: () => {},
|
|
647
|
+
timer,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
this.sendWS({
|
|
651
|
+
type: 'task',
|
|
652
|
+
id: taskId,
|
|
653
|
+
to: nodeId,
|
|
654
|
+
payload: { task: instruction },
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
return taskId;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* 发送 result 回给任务发起者
|
|
662
|
+
*/
|
|
663
|
+
sendResult(taskId: string, toNodeId: string, result: ResultPayload): void {
|
|
664
|
+
this.sendWS({
|
|
665
|
+
type: 'result',
|
|
666
|
+
id: taskId,
|
|
667
|
+
to: toNodeId,
|
|
668
|
+
payload: result,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ========================================================================
|
|
673
|
+
// 消息管理
|
|
674
|
+
// ========================================================================
|
|
675
|
+
|
|
676
|
+
getMessages(nodeId: string): InteractiveMessage[] {
|
|
677
|
+
return this.nodeMessages.get(nodeId) || [];
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
clearMessages(nodeId: string): void {
|
|
681
|
+
this.nodeMessages.delete(nodeId);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
getPendingTasks(): Map<string, PendingTask> {
|
|
685
|
+
return new Map(this.pendingTasks);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ========================================================================
|
|
689
|
+
// 健康检查
|
|
690
|
+
// ========================================================================
|
|
691
|
+
|
|
692
|
+
async checkConnection(): Promise<boolean> {
|
|
693
|
+
try {
|
|
694
|
+
const data = await this.httpGet('/');
|
|
695
|
+
return data?.status === 'running';
|
|
696
|
+
} catch {
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
getStatus(): {
|
|
702
|
+
registered: boolean;
|
|
703
|
+
connected: boolean;
|
|
704
|
+
nodeId: string | null;
|
|
705
|
+
clusterId: string | null;
|
|
706
|
+
parentId: string | null;
|
|
707
|
+
pendingTasks: number;
|
|
708
|
+
cachedNodes: number;
|
|
709
|
+
} {
|
|
710
|
+
return {
|
|
711
|
+
registered: this.isRegistered(),
|
|
712
|
+
connected: this.connected,
|
|
713
|
+
nodeId: this.config.nodeId || null,
|
|
714
|
+
clusterId: this.config.clusterId || null,
|
|
715
|
+
parentId: this.config.parentId ?? null,
|
|
716
|
+
pendingTasks: this.pendingTasks.size,
|
|
717
|
+
cachedNodes: this.nodesCache.length,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
}
|