@konglx/rotom 2.21.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 +417 -0
- package/bin/mesh-master.sh +439 -0
- package/bin/rotom +29 -0
- package/bin/rotom-link.sh +136 -0
- package/bin/rotom-send-with-status +57 -0
- package/bin/rotom-up.sh +428 -0
- package/dist/cli/ask.js +62 -0
- package/dist/cli/common.js +321 -0
- package/dist/cli/config.js +65 -0
- package/dist/cli/directory.js +17 -0
- package/dist/cli/executor.js +58 -0
- package/dist/cli/fed.js +91 -0
- package/dist/cli/group.js +273 -0
- package/dist/cli/identity.js +62 -0
- package/dist/cli/init.js +268 -0
- package/dist/cli/issue.js +202 -0
- package/dist/cli/join.js +170 -0
- package/dist/cli/link.js +47 -0
- package/dist/cli/master.js +51 -0
- package/dist/cli/memory.js +307 -0
- package/dist/cli/note.js +68 -0
- package/dist/cli/repo.js +77 -0
- package/dist/cli/rotom.js +277 -0
- package/dist/cli/routes.js +118 -0
- package/dist/cli/run.js +45 -0
- package/dist/cli/schedule.js +237 -0
- package/dist/cli/skill.js +173 -0
- package/dist/cli/team.js +106 -0
- package/dist/executor/claude-code-hook.cjs +80 -0
- package/dist/executor/cli-executor.js +8 -0
- package/dist/executor/executors/claude-code.js +780 -0
- package/dist/executor/executors/codex.js +719 -0
- package/dist/executor/executors/hermes-cli.js +855 -0
- package/dist/executor/executors/openclaw.js +467 -0
- package/dist/executor/executors/pi.js +514 -0
- package/dist/executor/index.js +269 -0
- package/dist/executor/jsonrpc-transport.js +125 -0
- package/dist/executor/process-runner.js +101 -0
- package/dist/executor/reasoning-status.js +83 -0
- package/dist/executor/repo-cache.js +502 -0
- package/dist/executor/session-store.js +188 -0
- package/dist/executor/worker-chat.js +257 -0
- package/dist/executor/worker-connection.js +89 -0
- package/dist/executor/worker-issue.js +264 -0
- package/dist/executor/worker.js +877 -0
- package/dist/link/pending-requests.js +72 -0
- package/dist/link/server.js +233 -0
- package/dist/link/visibility-store.js +58 -0
- package/dist/master/api/agents.js +333 -0
- package/dist/master/api/artifacts.js +271 -0
- package/dist/master/api/domains.js +64 -0
- package/dist/master/api/groups.js +635 -0
- package/dist/master/api/guidance-templates.js +147 -0
- package/dist/master/api/index.js +89 -0
- package/dist/master/api/issues-patrol.js +172 -0
- package/dist/master/api/issues.js +663 -0
- package/dist/master/api/links-patrol.js +168 -0
- package/dist/master/api/links.js +114 -0
- package/dist/master/api/memory.js +259 -0
- package/dist/master/api/messages.js +157 -0
- package/dist/master/api/notes.js +77 -0
- package/dist/master/api/schedule-patterns.js +133 -0
- package/dist/master/api/schedules.js +272 -0
- package/dist/master/api/sessions.js +158 -0
- package/dist/master/api/share.js +269 -0
- package/dist/master/api/skills.js +190 -0
- package/dist/master/api/teams.js +122 -0
- package/dist/master/api/uploads.js +245 -0
- package/dist/master/auth.js +134 -0
- package/dist/master/dashboard/animations/calico-dozing.apng +0 -0
- package/dist/master/dashboard/animations/calico-error.apng +0 -0
- package/dist/master/dashboard/animations/calico-happy.apng +0 -0
- package/dist/master/dashboard/animations/calico-notification.apng +0 -0
- package/dist/master/dashboard/animations/calico-sleeping.apng +0 -0
- package/dist/master/dashboard/animations/calico-thinking.apng +0 -0
- package/dist/master/dashboard/animations/calico-waking.apng +0 -0
- package/dist/master/dashboard/assets/ApprovalCard-C38VV6ko.css +1 -0
- package/dist/master/dashboard/assets/ApprovalCard-CHPh2dmE.js +17 -0
- package/dist/master/dashboard/assets/ArtifactPanel-P_2gAP7v.js +1 -0
- package/dist/master/dashboard/assets/ArtifactPanel-aGHySny5.css +1 -0
- package/dist/master/dashboard/assets/css.worker-DaIe3gwK.js +84 -0
- package/dist/master/dashboard/assets/editor.worker-BCzxt1at.js +12 -0
- package/dist/master/dashboard/assets/html.worker-CKrFyw_2.js +461 -0
- package/dist/master/dashboard/assets/index-CChrTn81.css +32 -0
- package/dist/master/dashboard/assets/index-Dhu4SN1z.js +181 -0
- package/dist/master/dashboard/assets/json.worker-B7c_PmGb.js +49 -0
- package/dist/master/dashboard/assets/markdown-CeN5IgdF.js +29 -0
- package/dist/master/dashboard/assets/monaco-core-DyX1CsEw.css +1 -0
- package/dist/master/dashboard/assets/monaco-core-oQiQUisy.js +833 -0
- package/dist/master/dashboard/assets/monaco-setup-CiOPQdmo.js +1 -0
- package/dist/master/dashboard/assets/react-vendor-C8IxlyCR.js +67 -0
- package/dist/master/dashboard/assets/ts.worker-BhkL8olL.js +51334 -0
- package/dist/master/dashboard/assets/useMonaco-ILb4vyPh.js +12 -0
- package/dist/master/dashboard/assets/vite-preload-CxJPbCTl.js +1 -0
- package/dist/master/dashboard/debug-auth.html +197 -0
- package/dist/master/dashboard/favicon.ico +0 -0
- package/dist/master/dashboard/index.html +20 -0
- package/dist/master/dashboard/rotom-avatar.png +0 -0
- package/dist/master/db/agent-sessions.js +60 -0
- package/dist/master/db/agent-visibility.js +64 -0
- package/dist/master/db/agents.js +119 -0
- package/dist/master/db/ask-bridges.js +157 -0
- package/dist/master/db/build-update.js +59 -0
- package/dist/master/db/core.js +82 -0
- package/dist/master/db/domains.js +80 -0
- package/dist/master/db/groups.js +316 -0
- package/dist/master/db/guidance-templates.js +58 -0
- package/dist/master/db/index.js +12 -0
- package/dist/master/db/internal.js +45 -0
- package/dist/master/db/issues-patrol.js +81 -0
- package/dist/master/db/issues.js +373 -0
- package/dist/master/db/links.js +221 -0
- package/dist/master/db/master-node.js +43 -0
- package/dist/master/db/memory.js +272 -0
- package/dist/master/db/messages.js +210 -0
- package/dist/master/db/notes.js +55 -0
- package/dist/master/db/schedule-patterns.js +56 -0
- package/dist/master/db/schedules.js +135 -0
- package/dist/master/db/skills.js +144 -0
- package/dist/master/db/team.js +88 -0
- package/dist/master/db/types.js +10 -0
- package/dist/master/db.js +12 -0
- package/dist/master/embedded.js +133 -0
- package/dist/master/federation/client.js +283 -0
- package/dist/master/federation/identity.js +133 -0
- package/dist/master/federation/manager.js +267 -0
- package/dist/master/federation/publisher.js +87 -0
- package/dist/master/federation/self-publisher.js +69 -0
- package/dist/master/federation/server.js +487 -0
- package/dist/master/group-paths.js +208 -0
- package/dist/master/offline-queue.js +38 -0
- package/dist/master/opc-bootstrap.js +245 -0
- package/dist/master/patrol-terminal.js +275 -0
- package/dist/master/repo-scan.js +188 -0
- package/dist/master/router.js +214 -0
- package/dist/master/scheduler-handlers.js +510 -0
- package/dist/master/scheduler.js +201 -0
- package/dist/master/server.js +203 -0
- package/dist/master/services/link-collector.js +82 -0
- package/dist/master/services/link-patrol-bootstrap.js +50 -0
- package/dist/master/services/memory-extract-prompt.js +34 -0
- package/dist/master/services/patrol-bootstrap.js +63 -0
- package/dist/master/share-tokens.js +56 -0
- package/dist/master/terminal-hub.js +300 -0
- package/dist/master/uploads.js +108 -0
- package/dist/master/util/fs.js +100 -0
- package/dist/master/util/paths.js +50 -0
- package/dist/master/util/persona.js +10 -0
- package/dist/master/ws-hub/connection.js +928 -0
- package/dist/master/ws-hub/conversation.js +290 -0
- package/dist/master/ws-hub/directory.js +70 -0
- package/dist/master/ws-hub/dispatch-enrich.js +34 -0
- package/dist/master/ws-hub/hub.js +136 -0
- package/dist/master/ws-hub/index.js +9 -0
- package/dist/master/ws-hub/internal.js +35 -0
- package/dist/master/ws-hub/routing.js +295 -0
- package/dist/master/ws-hub/sessions.js +130 -0
- package/dist/master/ws-hub.js +11 -0
- package/dist/shared/agent-profile.js +44 -0
- package/dist/shared/constants.js +55 -0
- package/dist/shared/dedup.js +33 -0
- package/dist/shared/group-context.js +62 -0
- package/dist/shared/json-codec.js +33 -0
- package/dist/shared/logger.js +136 -0
- package/dist/shared/mention.js +22 -0
- package/dist/shared/network.js +40 -0
- package/dist/shared/parse.js +18 -0
- package/dist/shared/prompt-composer.js +171 -0
- package/dist/shared/protocol/client-messages.js +8 -0
- package/dist/shared/protocol/enums.js +6 -0
- package/dist/shared/protocol/federation.js +62 -0
- package/dist/shared/protocol/guards.js +87 -0
- package/dist/shared/protocol/server-messages.js +8 -0
- package/dist/shared/protocol/types.js +8 -0
- package/dist/shared/protocol.js +19 -0
- package/dist/shared/readonly-allowlist.js +122 -0
- package/dist/shared/rotom-cli-prompt.js +23 -0
- package/dist/shared/skill-context.js +19 -0
- package/dist/shared/skill-md.js +43 -0
- package/dist/shared/slash-commands.js +50 -0
- package/dist/shared/time.js +80 -0
- package/dist/shared/title.js +46 -0
- package/dist/shared/url-extractor.js +99 -0
- package/migrations/001-schema.sql +942 -0
- package/package.json +68 -0
- package/scripts/fix-node-pty-perms.mjs +46 -0
- package/skill/rotom-a2a-communicate/SKILL.md +257 -0
- package/skill/rotom-bus-host/SKILL.md +78 -0
- package/skill/rotom-bus-host/scripts/poll-replies.sh +148 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Federation Client —— member master 主动连协调 master。
|
|
3
|
+
*
|
|
4
|
+
* Member 是 outbound 主动连接,所以**移动电脑/笔记本切网无影响**(只要协调 master
|
|
5
|
+
* 地址稳定)。断网自动重连,3s backoff,最多 30s。
|
|
6
|
+
*
|
|
7
|
+
* 职责(Phase 2):
|
|
8
|
+
* 1. 读 ~/.rotom/team.json → 启动 client
|
|
9
|
+
* 2. 连协调 master(/federation 路径)
|
|
10
|
+
* 3. 握手 FedHandshake → 处理 FedHandshakeAck(HOSTNAME_CONFLICT 时停止重连)
|
|
11
|
+
* 4. 维护本地 agent_visibility 缓存(从 FedDirectorySync 同步)
|
|
12
|
+
* 5. 提供 route() 接口给 Router 用 → 封装 FedRouteMessage 发给协调
|
|
13
|
+
* 6. 提供 publish() / unpublish() 给 publisher.ts 用
|
|
14
|
+
* 7. 接收 FedDeliver → 投递到本地 agent(通过 handlers.deliverLocal)
|
|
15
|
+
* 8. 接收 FedReply → 路由回本地 pendingRequests
|
|
16
|
+
*/
|
|
17
|
+
import WebSocket from "ws";
|
|
18
|
+
import { FED_PROTOCOL_VERSION, isFedMessage, } from "../../shared/protocol/federation.js";
|
|
19
|
+
import { createLogger } from "../../shared/logger.js";
|
|
20
|
+
const log = createLogger("fed-client");
|
|
21
|
+
export class FedClient {
|
|
22
|
+
db;
|
|
23
|
+
opts;
|
|
24
|
+
ws = null;
|
|
25
|
+
reconnectTimer;
|
|
26
|
+
connected = false;
|
|
27
|
+
handshakeAccepted = false;
|
|
28
|
+
stopped = false;
|
|
29
|
+
handlers = {};
|
|
30
|
+
constructor(db, opts) {
|
|
31
|
+
this.db = db;
|
|
32
|
+
this.opts = opts;
|
|
33
|
+
}
|
|
34
|
+
start() {
|
|
35
|
+
this.stopped = false;
|
|
36
|
+
this.connect();
|
|
37
|
+
}
|
|
38
|
+
stop() {
|
|
39
|
+
this.stopped = true;
|
|
40
|
+
if (this.reconnectTimer)
|
|
41
|
+
clearTimeout(this.reconnectTimer);
|
|
42
|
+
if (this.ws) {
|
|
43
|
+
try {
|
|
44
|
+
this.ws.close(1000, "fed client stopping");
|
|
45
|
+
}
|
|
46
|
+
catch { /* ignore */ }
|
|
47
|
+
this.ws = null;
|
|
48
|
+
}
|
|
49
|
+
this.connected = false;
|
|
50
|
+
this.handshakeAccepted = false;
|
|
51
|
+
}
|
|
52
|
+
setHandlers(handlers) {
|
|
53
|
+
this.handlers = handlers;
|
|
54
|
+
}
|
|
55
|
+
isConnected() {
|
|
56
|
+
return this.connected && this.handshakeAccepted;
|
|
57
|
+
}
|
|
58
|
+
/** 发消息到协调 master(握手成功后才有 ws) */
|
|
59
|
+
send(msg) {
|
|
60
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.handshakeAccepted)
|
|
61
|
+
return false;
|
|
62
|
+
this.ws.send(JSON.stringify(msg));
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
/** Router 调:把消息跨 master 投递(协调 master 中转到目标) */
|
|
66
|
+
route(requestId, from, to, payload, conversation) {
|
|
67
|
+
return this.send({
|
|
68
|
+
type: "fed_route",
|
|
69
|
+
teamId: this.opts.teamId,
|
|
70
|
+
requestId,
|
|
71
|
+
from,
|
|
72
|
+
to,
|
|
73
|
+
payload,
|
|
74
|
+
conversation,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** publisher.ts 调:上报本地 agent 状态 */
|
|
78
|
+
publish(agents) {
|
|
79
|
+
if (!this.handshakeAccepted)
|
|
80
|
+
return false;
|
|
81
|
+
return this.send({
|
|
82
|
+
type: "fed_agent_publish",
|
|
83
|
+
teamId: this.opts.teamId,
|
|
84
|
+
masterId: this.opts.identity.id,
|
|
85
|
+
hostname: this.opts.identity.hostname,
|
|
86
|
+
agents: agents.map((a) => ({
|
|
87
|
+
hostname: this.opts.identity.hostname,
|
|
88
|
+
name: a.name,
|
|
89
|
+
displayName: a.displayName,
|
|
90
|
+
isHuman: a.isHuman,
|
|
91
|
+
online: a.online,
|
|
92
|
+
})),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
unpublish(agentNames) {
|
|
96
|
+
if (!this.handshakeAccepted)
|
|
97
|
+
return false;
|
|
98
|
+
return this.send({
|
|
99
|
+
type: "fed_agent_unpublish",
|
|
100
|
+
teamId: this.opts.teamId,
|
|
101
|
+
masterId: this.opts.identity.id,
|
|
102
|
+
agents: agentNames.map((name) => ({ hostname: this.opts.identity.hostname, name })),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Member 本地 agent 给一个 federated 请求回了消息 → 发 FedReply 给协调 master,
|
|
107
|
+
* 协调 master 广播给所有 member(含发起方),发起方 FedClient.handleReply 用 requestId
|
|
108
|
+
* 反查自己 pendingRequests 解 promise。
|
|
109
|
+
*/
|
|
110
|
+
reply(requestId, from, payload) {
|
|
111
|
+
return this.send({
|
|
112
|
+
type: "fed_reply",
|
|
113
|
+
requestId,
|
|
114
|
+
from,
|
|
115
|
+
payload,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// ─── 内部 ─────────────────────────────────────────────────────────────────
|
|
119
|
+
connect() {
|
|
120
|
+
if (this.stopped)
|
|
121
|
+
return;
|
|
122
|
+
const endpoint = this.opts.coordEndpoints[0];
|
|
123
|
+
if (!endpoint) {
|
|
124
|
+
log.error("[fed-client] no coord endpoint configured");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const url = endpoint.replace(/\/$/, "") + "/federation";
|
|
128
|
+
log.info(`[fed-client] connecting to ${url}...`);
|
|
129
|
+
try {
|
|
130
|
+
this.ws = new WebSocket(url);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
log.error(`[fed-client] ws construct failed: ${err.message}`);
|
|
134
|
+
this.scheduleReconnect();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.ws.on("open", () => this.handleOpen());
|
|
138
|
+
this.ws.on("message", (raw) => this.handleMessage(raw.toString()));
|
|
139
|
+
this.ws.on("close", (code, reason) => this.handleClose(code, reason.toString()));
|
|
140
|
+
this.ws.on("error", (err) => {
|
|
141
|
+
log.error(`[fed-client] ws error: ${err.message}`);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
handleOpen() {
|
|
145
|
+
log.info("[fed-client] connected, sending handshake...");
|
|
146
|
+
this.connected = true;
|
|
147
|
+
this.handshakeAccepted = false;
|
|
148
|
+
// 握手(无认证,只声明身份)
|
|
149
|
+
const handshake = {
|
|
150
|
+
type: "fed_handshake",
|
|
151
|
+
masterId: this.opts.identity.id,
|
|
152
|
+
hostname: this.opts.identity.hostname,
|
|
153
|
+
role: this.opts.role,
|
|
154
|
+
protocol: FED_PROTOCOL_VERSION,
|
|
155
|
+
};
|
|
156
|
+
this.ws?.send(JSON.stringify(handshake));
|
|
157
|
+
// 握手超时(10s)
|
|
158
|
+
setTimeout(() => {
|
|
159
|
+
if (!this.handshakeAccepted && this.ws) {
|
|
160
|
+
log.warn("[fed-client] handshake timeout, closing");
|
|
161
|
+
try {
|
|
162
|
+
this.ws.close(4401, "handshake timeout");
|
|
163
|
+
}
|
|
164
|
+
catch { /* ignore */ }
|
|
165
|
+
}
|
|
166
|
+
}, 10_000);
|
|
167
|
+
}
|
|
168
|
+
handleMessage(raw) {
|
|
169
|
+
let msg;
|
|
170
|
+
try {
|
|
171
|
+
const parsed = JSON.parse(raw);
|
|
172
|
+
if (!isFedMessage(parsed)) {
|
|
173
|
+
log.warn("[fed-client] invalid fed message");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
msg = parsed;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
log.warn("[fed-client] invalid JSON");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (!this.handshakeAccepted) {
|
|
183
|
+
if (msg.type === "fed_handshake_ack") {
|
|
184
|
+
this.handleHandshakeAck(msg);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
log.warn(`[fed-client] pre-handshake unexpected: ${msg.type}`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
switch (msg.type) {
|
|
191
|
+
case "fed_directory_sync":
|
|
192
|
+
return this.handleDirectorySync(msg);
|
|
193
|
+
case "fed_deliver":
|
|
194
|
+
return this.handleDeliver(msg);
|
|
195
|
+
case "fed_reply":
|
|
196
|
+
return this.handleReply(msg);
|
|
197
|
+
case "fed_route_failed":
|
|
198
|
+
return this.handleRouteFailed(msg);
|
|
199
|
+
case "fed_handshake_ack":
|
|
200
|
+
log.warn("[fed-client] duplicate handshake_ack, ignoring");
|
|
201
|
+
return;
|
|
202
|
+
default:
|
|
203
|
+
// 其他类型(publish/unpublish/route)是 member → coord 方向,member 不该收到
|
|
204
|
+
log.warn(`[fed-client] unexpected message type: ${msg.type}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
handleHandshakeAck(msg) {
|
|
208
|
+
if (!msg.accepted) {
|
|
209
|
+
log.error(`[fed-client] handshake rejected: ${msg.error ?? "unknown"}`);
|
|
210
|
+
if (msg.error === "HOSTNAME_CONFLICT") {
|
|
211
|
+
log.error(`[fed-client] hostname "${this.opts.identity.hostname}" already taken in team — change ROTOM_HOSTNAME and restart`);
|
|
212
|
+
// 不重连,等用户改 hostname
|
|
213
|
+
this.stopped = true;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
this.ws?.close(4404, msg.error ?? "rejected");
|
|
217
|
+
}
|
|
218
|
+
catch { /* ignore */ }
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
this.handshakeAccepted = true;
|
|
222
|
+
log.info(`[fed-client] joined team ${msg.teamId} (coord=${msg.serverMasterId}/${msg.serverHostname})`);
|
|
223
|
+
}
|
|
224
|
+
handleDirectorySync(msg) {
|
|
225
|
+
// 全量 upsert + remove(简单实现:Phase 3 可优化为 diff)
|
|
226
|
+
if (msg.remove.length > 0) {
|
|
227
|
+
// remove 需要逐条删
|
|
228
|
+
for (const r of msg.remove) {
|
|
229
|
+
this.db.removeVisibleAgent(msg.teamId, r.masterId, r.name);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (msg.upsert.length > 0) {
|
|
233
|
+
// upsert 全量替换:简单起见,先 clear 再 insert(Phase 3 优化)
|
|
234
|
+
// 注意只 clear 非本机的(本机的由 publisher 维护)
|
|
235
|
+
const localMasterId = this.opts.identity.id;
|
|
236
|
+
const all = this.db.listVisibleAgents(msg.teamId);
|
|
237
|
+
for (const r of all) {
|
|
238
|
+
if (r.master_id !== localMasterId) {
|
|
239
|
+
this.db.removeVisibleAgent(msg.teamId, r.master_id, r.agent_name);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for (const u of msg.upsert) {
|
|
243
|
+
if (u.masterId === localMasterId)
|
|
244
|
+
continue; // 不缓存自己
|
|
245
|
+
this.db.upsertVisibleAgent({
|
|
246
|
+
team_id: msg.teamId,
|
|
247
|
+
master_id: u.masterId,
|
|
248
|
+
agent_name: u.name,
|
|
249
|
+
hostname: u.hostname,
|
|
250
|
+
display_name: u.displayName,
|
|
251
|
+
is_human: u.isHuman,
|
|
252
|
+
online: u.online,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
handleDeliver(msg) {
|
|
258
|
+
const ok = this.handlers.deliverLocal?.(msg) ?? false;
|
|
259
|
+
if (!ok) {
|
|
260
|
+
log.warn(`[fed-client] local deliver failed for requestId=${msg.requestId}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
handleReply(msg) {
|
|
264
|
+
this.handlers.handleReply?.(msg);
|
|
265
|
+
}
|
|
266
|
+
handleRouteFailed(msg) {
|
|
267
|
+
this.handlers.handleRouteFailed?.(msg);
|
|
268
|
+
}
|
|
269
|
+
handleClose(code, reason) {
|
|
270
|
+
this.connected = false;
|
|
271
|
+
this.handshakeAccepted = false;
|
|
272
|
+
this.ws = null;
|
|
273
|
+
log.info(`[fed-client] disconnected (code=${code}, reason=${reason})`);
|
|
274
|
+
this.scheduleReconnect();
|
|
275
|
+
}
|
|
276
|
+
scheduleReconnect() {
|
|
277
|
+
if (this.stopped)
|
|
278
|
+
return;
|
|
279
|
+
if (this.reconnectTimer)
|
|
280
|
+
clearTimeout(this.reconnectTimer);
|
|
281
|
+
this.reconnectTimer = setTimeout(() => this.connect(), 3_000);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Master identity — masterId 生成、hostname 解析与校验、role 解析。
|
|
3
|
+
*
|
|
4
|
+
* masterId 是 8 字符 base36 小写短 ID(36^8 ≈ 2.8 万亿),首次启动生成、
|
|
5
|
+
* 持久化在 `~/.rotom/master.json`,之后永远稳定。机器换网络 / 改 IP / 改
|
|
6
|
+
* os.hostname 都不影响 masterId —— 它是路由的真正主键,IP 不可靠。
|
|
7
|
+
*
|
|
8
|
+
* hostname 仅作显示用(`alice@hostA`),不是路由键;但启动时强制校验拒绝 IP,
|
|
9
|
+
* 因为移动电脑 IP 会变。优先级:
|
|
10
|
+
* ROTOM_HOSTNAME 环境变量 > ~/.rotom/hostname 文件 > os.hostname()
|
|
11
|
+
*
|
|
12
|
+
* role 同样每次启动从 ROTOM_MASTER_ROLE 解析;Phase 1 实际只启用 standalone 行为,
|
|
13
|
+
* coordination/member 的语义在 Phase 2 federation 落地后才有意义。
|
|
14
|
+
*/
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
20
|
+
const ID_LENGTH = 8;
|
|
21
|
+
const ID_PATTERN = /^[0-9a-z]{8}$/;
|
|
22
|
+
// IPv4 字面量: 192.168.1.1 / 10.0.0.1
|
|
23
|
+
const IPV4_PATTERN = /^\d{1,3}(\.\d{1,3}){3}$/;
|
|
24
|
+
// IPv6 字面量(粗略:包含 : 的十六进制串)
|
|
25
|
+
const IPV6_PATTERN = /^[0-9a-fA-F:]+$/;
|
|
26
|
+
const MAX_HOSTNAME_LEN = 63;
|
|
27
|
+
/** 生成一个 8 字符 base36 小写 masterId。 */
|
|
28
|
+
export function generateMasterId() {
|
|
29
|
+
const bytes = crypto.randomBytes(ID_LENGTH);
|
|
30
|
+
let out = "";
|
|
31
|
+
for (let i = 0; i < ID_LENGTH; i++) {
|
|
32
|
+
out += ALPHABET[bytes[i] % ALPHABET.length];
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 校验 hostname 是否合法。拒绝 IP 字面量(移动电脑 IP 不稳定)、空串、超长串。
|
|
38
|
+
* 注意:`localhost`、`.local` mDNS 名等不拒绝 —— 由用户判断是否稳定。
|
|
39
|
+
*/
|
|
40
|
+
export function isValidHostname(name) {
|
|
41
|
+
if (!name || typeof name !== "string")
|
|
42
|
+
return false;
|
|
43
|
+
if (name.length === 0 || name.length > MAX_HOSTNAME_LEN)
|
|
44
|
+
return false;
|
|
45
|
+
if (IPV4_PATTERN.test(name))
|
|
46
|
+
return false;
|
|
47
|
+
if (name.includes(":") && IPV6_PATTERN.test(name))
|
|
48
|
+
return false;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 解析本机 master 身份。首次启动会生成 masterId 并写入 `~/.rotom/master.json`;
|
|
53
|
+
* 后续启动读回 masterId(永远稳定),hostname / role 则每次重新解析(可变)。
|
|
54
|
+
*
|
|
55
|
+
* @throws 如果 hostname 校验失败(IP 字面量、空串等)
|
|
56
|
+
*/
|
|
57
|
+
export function getMasterIdentity(opts = {}) {
|
|
58
|
+
const rotomHome = opts.rotomHome || process.env.ROTOM_HOME || path.join(os.homedir(), ".rotom");
|
|
59
|
+
const masterJsonPath = path.join(rotomHome, "master.json");
|
|
60
|
+
const hostname = resolveHostname(rotomHome);
|
|
61
|
+
if (!isValidHostname(hostname)) {
|
|
62
|
+
throw new Error(`Invalid hostname "${hostname}". ` +
|
|
63
|
+
`Set ROTOM_HOSTNAME to a stable machine name ` +
|
|
64
|
+
`(IP literals are not allowed because laptop IPs change across networks).`);
|
|
65
|
+
}
|
|
66
|
+
const role = resolveRole();
|
|
67
|
+
// teamName 优先级:ROTOM_TEAM_NAME 环境变量 > master.json 持久化值 > undefined(由 OPC bootstrap 兜底)
|
|
68
|
+
// OPC bootstrap 会查本机真人 agent,用其 name + "团队" 作默认(如"西花团队")
|
|
69
|
+
const storedTeamName = readStoredIdentity(masterJsonPath)?.teamName;
|
|
70
|
+
const teamName = resolveTeamName() ?? storedTeamName ?? "";
|
|
71
|
+
let stored = readStoredIdentity(masterJsonPath);
|
|
72
|
+
if (!stored || !ID_PATTERN.test(stored.id)) {
|
|
73
|
+
stored = {
|
|
74
|
+
id: generateMasterId(),
|
|
75
|
+
hostname,
|
|
76
|
+
role,
|
|
77
|
+
teamName: teamName || undefined,
|
|
78
|
+
createdAt: new Date().toISOString(),
|
|
79
|
+
};
|
|
80
|
+
writeStoredIdentity(masterJsonPath, stored);
|
|
81
|
+
}
|
|
82
|
+
return { id: stored.id, hostname, role, teamName };
|
|
83
|
+
}
|
|
84
|
+
/** 解析 ROTOM_HOME(给外部模块用,避免重复实现)。 */
|
|
85
|
+
export function resolveRotomHome() {
|
|
86
|
+
return process.env.ROTOM_HOME || path.join(os.homedir(), ".rotom");
|
|
87
|
+
}
|
|
88
|
+
function resolveHostname(rotomHome) {
|
|
89
|
+
const fromEnv = process.env.ROTOM_HOSTNAME;
|
|
90
|
+
if (fromEnv && fromEnv.trim())
|
|
91
|
+
return fromEnv.trim();
|
|
92
|
+
const hostnameFile = path.join(rotomHome, "hostname");
|
|
93
|
+
try {
|
|
94
|
+
const raw = fs.readFileSync(hostnameFile, "utf-8").trim();
|
|
95
|
+
if (raw)
|
|
96
|
+
return raw;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// 文件不存在,fallback
|
|
100
|
+
}
|
|
101
|
+
return os.hostname();
|
|
102
|
+
}
|
|
103
|
+
function resolveRole() {
|
|
104
|
+
const raw = process.env.ROTOM_MASTER_ROLE;
|
|
105
|
+
if (raw === "standalone" || raw === "coordination" || raw === "member") {
|
|
106
|
+
return raw;
|
|
107
|
+
}
|
|
108
|
+
return "standalone";
|
|
109
|
+
}
|
|
110
|
+
/** 解析团队展示名。优先 ROTOM_TEAM_NAME 环境变量,否则用 master.json 里的,否则 = hostname。 */
|
|
111
|
+
function resolveTeamName() {
|
|
112
|
+
const fromEnv = process.env.ROTOM_TEAM_NAME;
|
|
113
|
+
if (fromEnv && fromEnv.trim())
|
|
114
|
+
return fromEnv.trim();
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
function readStoredIdentity(p) {
|
|
118
|
+
try {
|
|
119
|
+
const raw = fs.readFileSync(p, "utf-8");
|
|
120
|
+
const parsed = JSON.parse(raw);
|
|
121
|
+
if (typeof parsed.id === "string" && typeof parsed.hostname === "string" && typeof parsed.role === "string") {
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function writeStoredIdentity(p, identity) {
|
|
131
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
132
|
+
fs.writeFileSync(p, JSON.stringify(identity, null, 2) + "\n", "utf-8");
|
|
133
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FederationManager —— federation 子系统的运行时管理器。
|
|
3
|
+
*
|
|
4
|
+
* 把 fedClient / fedPublisher / fedServer 的创建/启动/停止封装在一起,
|
|
5
|
+
* 让 API 层(POST /api/teams/join / leave)能在 master 运行时切换 federation 状态,
|
|
6
|
+
* 不需要重启 master。
|
|
7
|
+
*
|
|
8
|
+
* 生命周期:
|
|
9
|
+
* - initFromRole():master 启动时调用,根据 identity.role 启动对应子系统
|
|
10
|
+
* - joinTeam(teamId, coordEndpoints):runtime 从 standalone 切到 member
|
|
11
|
+
* - leaveTeam():runtime 从 member 切回 standalone
|
|
12
|
+
*
|
|
13
|
+
* 单例:server.ts 初始化一次,API 层通过 getFederationManager() 访问。
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { FedServer } from "./server.js";
|
|
18
|
+
import { FedClient } from "./client.js";
|
|
19
|
+
import { FedPublisher } from "./publisher.js";
|
|
20
|
+
import { SelfPublisher } from "./self-publisher.js";
|
|
21
|
+
import { createLogger } from "../../shared/logger.js";
|
|
22
|
+
const log = createLogger("fed-manager");
|
|
23
|
+
export class FederationManager {
|
|
24
|
+
opts;
|
|
25
|
+
fedClient = null;
|
|
26
|
+
fedPublisher = null;
|
|
27
|
+
fedServer = null;
|
|
28
|
+
fedSelfPublisher = null;
|
|
29
|
+
constructor(opts) {
|
|
30
|
+
this.opts = opts;
|
|
31
|
+
}
|
|
32
|
+
/** Master 启动时调用:根据 identity.role + team.json 启动对应子系统 */
|
|
33
|
+
initFromRole() {
|
|
34
|
+
const { identity, db, hub, router, httpServer, rotomHome, masterPort } = this.opts;
|
|
35
|
+
if (identity.role === "member") {
|
|
36
|
+
const teamConfigPath = path.join(rotomHome, "team.json");
|
|
37
|
+
if (fs.existsSync(teamConfigPath)) {
|
|
38
|
+
this.startMember(JSON.parse(fs.readFileSync(teamConfigPath, "utf-8")));
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
log.warn(`role=member but no ${teamConfigPath} — falling back to standalone`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else if (identity.role === "coordination") {
|
|
45
|
+
this.startCoordination();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Runtime:从 standalone 切到 member,连协调 master */
|
|
49
|
+
async joinTeam(input) {
|
|
50
|
+
if (this.fedClient) {
|
|
51
|
+
throw new Error("Already a member of a team — leave first");
|
|
52
|
+
}
|
|
53
|
+
const { identity, db, hub, router, rotomHome } = this.opts;
|
|
54
|
+
// 1. 连协调 master,握手拿 teamId(协调的 masterId)
|
|
55
|
+
const teamId = await this.fetchCoordIdentity(input.coordEndpoint);
|
|
56
|
+
const teamName = input.teamName || `团队@${identity.hostname}`;
|
|
57
|
+
// 2. 写 team.json
|
|
58
|
+
const teamConfigPath = path.join(rotomHome, "team.json");
|
|
59
|
+
fs.writeFileSync(teamConfigPath, JSON.stringify({
|
|
60
|
+
id: teamId,
|
|
61
|
+
name: teamName,
|
|
62
|
+
coord_endpoints: [input.coordEndpoint],
|
|
63
|
+
}, null, 2) + "\n", "utf-8");
|
|
64
|
+
// 3. 建本地 team 行(本机视角)
|
|
65
|
+
if (!db.getTeam(teamId)) {
|
|
66
|
+
db.insertTeam({
|
|
67
|
+
id: teamId,
|
|
68
|
+
name: teamName,
|
|
69
|
+
my_role: "member",
|
|
70
|
+
coord_endpoints: input.coordEndpoint,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// 4. 启动 fedClient + fedPublisher
|
|
74
|
+
this.startMember({ id: teamId, name: teamName, coord_endpoints: [input.coordEndpoint] });
|
|
75
|
+
return { teamId, teamName };
|
|
76
|
+
}
|
|
77
|
+
/** Runtime:离开团队,切回 standalone */
|
|
78
|
+
leaveTeam() {
|
|
79
|
+
const { rotomHome, db, router } = this.opts;
|
|
80
|
+
// 停 fedClient + fedPublisher
|
|
81
|
+
this.fedPublisher?.stop();
|
|
82
|
+
this.fedClient?.stop();
|
|
83
|
+
this.fedPublisher = null;
|
|
84
|
+
this.fedClient = null;
|
|
85
|
+
// 撤回 Router 的 federation 注入
|
|
86
|
+
// (setFederation 接受 undefined?不,签名要求 client。简单起见用类型断言)
|
|
87
|
+
router.fedClient = undefined;
|
|
88
|
+
router.teamId = undefined;
|
|
89
|
+
// 删 team.json
|
|
90
|
+
const teamConfigPath = path.join(rotomHome, "team.json");
|
|
91
|
+
try {
|
|
92
|
+
fs.unlinkSync(teamConfigPath);
|
|
93
|
+
}
|
|
94
|
+
catch { /* 不存在 */ }
|
|
95
|
+
// 清本地 team 行 + peer 缓存 + agent_visibility 缓存
|
|
96
|
+
const teams = db.listTeams();
|
|
97
|
+
for (const t of teams) {
|
|
98
|
+
if (t.my_role === "member") {
|
|
99
|
+
db.clearPeers(t.id);
|
|
100
|
+
db.clearVisibleAgents(t.id);
|
|
101
|
+
db.deleteTeam(t.id);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
log.info("Left team — back to standalone");
|
|
105
|
+
}
|
|
106
|
+
getRole() {
|
|
107
|
+
return this.opts.identity.role;
|
|
108
|
+
}
|
|
109
|
+
getFedClient() {
|
|
110
|
+
return this.fedClient;
|
|
111
|
+
}
|
|
112
|
+
// ─── 内部 ─────────────────────────────────────────────────────────────
|
|
113
|
+
/** 拉协调 master 的 /api/identity 拿 masterId(作 teamId) */
|
|
114
|
+
async fetchCoordIdentity(coordEndpoint) {
|
|
115
|
+
const httpUrl = coordEndpoint
|
|
116
|
+
.replace(/^wss:/, "https:")
|
|
117
|
+
.replace(/^ws:/, "http:")
|
|
118
|
+
.replace(/\/$/, "");
|
|
119
|
+
const res = await fetch(`${httpUrl}/api/identity`);
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
throw new Error(`Failed to fetch coord identity: ${res.status} ${res.statusText}`);
|
|
122
|
+
}
|
|
123
|
+
const data = await res.json();
|
|
124
|
+
if (data.role !== "coordination") {
|
|
125
|
+
throw new Error(`Target master is not a coordination master (role=${data.role})`);
|
|
126
|
+
}
|
|
127
|
+
return data.id;
|
|
128
|
+
}
|
|
129
|
+
startMember(teamCfg) {
|
|
130
|
+
const { identity, db, hub, router } = this.opts;
|
|
131
|
+
this.fedClient = new FedClient(db, {
|
|
132
|
+
identity,
|
|
133
|
+
coordEndpoints: teamCfg.coord_endpoints,
|
|
134
|
+
teamId: teamCfg.id,
|
|
135
|
+
role: "member",
|
|
136
|
+
});
|
|
137
|
+
this.fedClient.setHandlers({
|
|
138
|
+
deliverLocal: (msg) => {
|
|
139
|
+
const target = db.getLocalAgentByName(msg.to.name) ?? db.getAgentByName(msg.to.name);
|
|
140
|
+
if (!target)
|
|
141
|
+
return false;
|
|
142
|
+
const fromAgent = db.getAgentByName(msg.from.name);
|
|
143
|
+
const fromInfo = {
|
|
144
|
+
name: msg.from.name,
|
|
145
|
+
status: "online",
|
|
146
|
+
domain: fromAgent?.domain ?? undefined,
|
|
147
|
+
description: fromAgent?.description ?? undefined,
|
|
148
|
+
enabled: (fromAgent?.enabled ?? 1) !== 0,
|
|
149
|
+
};
|
|
150
|
+
// 注册 federated pending request,让本地 agent 的 a2a_reply 走 fedReplyHook
|
|
151
|
+
router.registerFederatedPendingRequest(msg.requestId, msg.conversation);
|
|
152
|
+
return hub.sendToAgent(target.id, {
|
|
153
|
+
type: "a2a_message",
|
|
154
|
+
requestId: msg.requestId,
|
|
155
|
+
from: fromInfo,
|
|
156
|
+
payload: msg.payload,
|
|
157
|
+
routeType: "federated",
|
|
158
|
+
conversation: msg.conversation,
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
handleReply: (msg) => {
|
|
162
|
+
const targetId = router.resolveReplyTarget(msg.requestId);
|
|
163
|
+
if (!targetId)
|
|
164
|
+
return;
|
|
165
|
+
hub.sendToAgent(targetId, {
|
|
166
|
+
type: "a2a_reply",
|
|
167
|
+
requestId: msg.requestId,
|
|
168
|
+
from: msg.from.name,
|
|
169
|
+
payload: msg.payload,
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
handleRouteFailed: (msg) => {
|
|
173
|
+
// Member 端发起跨机请求(本机 alice 经 fedClient.route 给 B 上 carol)失败时,
|
|
174
|
+
// 把失败当作 a2a_reply 回给本机 alice,让 CLI/worker 拿到错误。
|
|
175
|
+
// 注意:这里 router.pendingRequests 里 entry 是 isFederated=false(本机发起,
|
|
176
|
+
// 不是远端投来),所以不用 consumeFederatedPendingRequest。
|
|
177
|
+
// MVP:member 起发起跨机请求是 Phase 4 群组跨机场景,这里先 log + reject 思路 stub。
|
|
178
|
+
log.warn(`[fed-manager] route_failed for requestId=${msg.requestId} reason=${msg.reason} ` +
|
|
179
|
+
`to=${msg.to?.name}@${msg.to?.hostname} — Phase 4 才接 member 端 PendingRequests reject`);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
this.fedClient.start();
|
|
183
|
+
router.setFederation(this.fedClient, teamCfg.id, identity.hostname);
|
|
184
|
+
// 本地 agent 给 federated 请求回复 → 通过 fedClient 把 FedReply 发给协调 master
|
|
185
|
+
router.fedReplyHook = (requestId, fromName, payload) => {
|
|
186
|
+
if (!this.fedClient)
|
|
187
|
+
return;
|
|
188
|
+
this.fedClient.reply(requestId, { hostname: identity.hostname, name: fromName }, payload);
|
|
189
|
+
router.consumeFederatedPendingRequest(requestId);
|
|
190
|
+
};
|
|
191
|
+
this.fedPublisher = new FedPublisher(db, this.fedClient, { teamId: teamCfg.id });
|
|
192
|
+
this.fedPublisher.start();
|
|
193
|
+
log.info(`Started member mode — team=${teamCfg.id}, endpoints=${teamCfg.coord_endpoints.join(",")}`);
|
|
194
|
+
}
|
|
195
|
+
startCoordination() {
|
|
196
|
+
const { identity, db, httpServer, masterPort, router } = this.opts;
|
|
197
|
+
const teamId = identity.id;
|
|
198
|
+
const teamName = identity.teamName || `${identity.hostname} 团队`;
|
|
199
|
+
if (!db.getTeam(teamId)) {
|
|
200
|
+
db.insertTeam({
|
|
201
|
+
id: teamId,
|
|
202
|
+
name: teamName,
|
|
203
|
+
my_role: "coordination",
|
|
204
|
+
coord_endpoints: `ws://${identity.hostname}:${masterPort}`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
db.upsertPeer({
|
|
208
|
+
team_id: teamId,
|
|
209
|
+
master_id: identity.id,
|
|
210
|
+
hostname: identity.hostname,
|
|
211
|
+
role: "coordination",
|
|
212
|
+
});
|
|
213
|
+
this.fedServer = new FedServer(httpServer, db, { identity, teamId });
|
|
214
|
+
this.fedServer.setHandlers({
|
|
215
|
+
deliverLocal: (msg) => {
|
|
216
|
+
const { hub, db, router } = this.opts;
|
|
217
|
+
const target = db.getLocalAgentByName(msg.to.name) ?? db.getAgentByName(msg.to.name);
|
|
218
|
+
if (!target)
|
|
219
|
+
return false;
|
|
220
|
+
const fromAgent = db.getAgentByName(msg.from.name);
|
|
221
|
+
const fromInfo = {
|
|
222
|
+
name: msg.from.name,
|
|
223
|
+
status: "online",
|
|
224
|
+
domain: fromAgent?.domain ?? undefined,
|
|
225
|
+
description: fromAgent?.description ?? undefined,
|
|
226
|
+
enabled: (fromAgent?.enabled ?? 1) !== 0,
|
|
227
|
+
};
|
|
228
|
+
// 注册 federated pending request,让本地 agent 的 a2a_reply 走 fedReplyHook
|
|
229
|
+
router.registerFederatedPendingRequest(msg.requestId, msg.conversation);
|
|
230
|
+
return hub.sendToAgent(target.id, {
|
|
231
|
+
type: "a2a_message",
|
|
232
|
+
requestId: msg.requestId,
|
|
233
|
+
from: fromInfo,
|
|
234
|
+
payload: msg.payload,
|
|
235
|
+
routeType: "federated",
|
|
236
|
+
conversation: msg.conversation,
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
this.fedServer.start();
|
|
241
|
+
// 本地 agent 给 federated 请求回复 → 通过 fedServer 广播 FedReply 给所有 member
|
|
242
|
+
router.fedReplyHook = (requestId, fromName, payload) => {
|
|
243
|
+
if (!this.fedServer)
|
|
244
|
+
return;
|
|
245
|
+
this.fedServer.sendReply(requestId, { hostname: identity.hostname, name: fromName }, payload);
|
|
246
|
+
router.consumeFederatedPendingRequest(requestId);
|
|
247
|
+
};
|
|
248
|
+
this.fedSelfPublisher = new SelfPublisher(db, identity);
|
|
249
|
+
this.fedSelfPublisher.start();
|
|
250
|
+
log.info(`Started coordination mode — team=${teamId}`);
|
|
251
|
+
}
|
|
252
|
+
stop() {
|
|
253
|
+
this.fedSelfPublisher?.stop();
|
|
254
|
+
this.fedPublisher?.stop();
|
|
255
|
+
this.fedClient?.stop();
|
|
256
|
+
this.fedServer?.stop();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// 全局单例
|
|
260
|
+
let manager = null;
|
|
261
|
+
export function initFederationManager(opts) {
|
|
262
|
+
manager = new FederationManager(opts);
|
|
263
|
+
return manager;
|
|
264
|
+
}
|
|
265
|
+
export function getFederationManager() {
|
|
266
|
+
return manager;
|
|
267
|
+
}
|