@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,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PendingRequests —— rotom-link 端的 requestId → Promise 映射。
|
|
3
|
+
*
|
|
4
|
+
* CLI 调 POST /fed/ask 时,daemon 生成 requestId,记录到这里,调
|
|
5
|
+
* fedClient.route(requestId, ...),然后 await promise。
|
|
6
|
+
*
|
|
7
|
+
* 协调 master 把 FedReply 广播回来,handleReply 用 requestId 解对应 promise。
|
|
8
|
+
* 5min 超时(对齐 src/cli/ask.ts 的 bridge 超时)避免内存泄漏。
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
11
|
+
export class PendingRequests {
|
|
12
|
+
map = new Map();
|
|
13
|
+
timeoutMs;
|
|
14
|
+
constructor(timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
15
|
+
this.timeoutMs = timeoutMs;
|
|
16
|
+
}
|
|
17
|
+
register(requestId) {
|
|
18
|
+
let resolve;
|
|
19
|
+
let reject;
|
|
20
|
+
const promise = new Promise((res, rej) => {
|
|
21
|
+
resolve = res;
|
|
22
|
+
reject = rej;
|
|
23
|
+
});
|
|
24
|
+
const timer = setTimeout(() => {
|
|
25
|
+
if (this.map.has(requestId)) {
|
|
26
|
+
this.map.delete(requestId);
|
|
27
|
+
reject(new Error(`fed ask timeout after ${this.timeoutMs}ms (requestId=${requestId})`));
|
|
28
|
+
}
|
|
29
|
+
}, this.timeoutMs);
|
|
30
|
+
this.map.set(requestId, { resolve, reject, createdAt: Date.now(), timer });
|
|
31
|
+
return {
|
|
32
|
+
promise,
|
|
33
|
+
cancel: (err) => {
|
|
34
|
+
const p = this.map.get(requestId);
|
|
35
|
+
if (p) {
|
|
36
|
+
clearTimeout(p.timer);
|
|
37
|
+
this.map.delete(requestId);
|
|
38
|
+
reject(err);
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
resolve(requestId, message) {
|
|
44
|
+
const p = this.map.get(requestId);
|
|
45
|
+
if (!p)
|
|
46
|
+
return false;
|
|
47
|
+
clearTimeout(p.timer);
|
|
48
|
+
this.map.delete(requestId);
|
|
49
|
+
p.resolve(message);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
reject(requestId, err) {
|
|
53
|
+
const p = this.map.get(requestId);
|
|
54
|
+
if (!p)
|
|
55
|
+
return false;
|
|
56
|
+
clearTimeout(p.timer);
|
|
57
|
+
this.map.delete(requestId);
|
|
58
|
+
p.reject(err);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
/** daemon 关停时拒绝所有 pending,避免 CLI hang */
|
|
62
|
+
rejectAll(err) {
|
|
63
|
+
for (const [, p] of this.map) {
|
|
64
|
+
clearTimeout(p.timer);
|
|
65
|
+
p.reject(err);
|
|
66
|
+
}
|
|
67
|
+
this.map.clear();
|
|
68
|
+
}
|
|
69
|
+
size() {
|
|
70
|
+
return this.map.size;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rotom-link daemon —— 轻量 federation 客户端节点。
|
|
3
|
+
*
|
|
4
|
+
* 角色定位:不带本机 agent 的 member。复用 FedClient 连协调 master,
|
|
5
|
+
* 接收 FedDirectorySync 缓存可见 agent,通过 FedRouteMessage 跨机投递
|
|
6
|
+
* 消息给其他 member 上的 agent(或协调 master 自身的 agent),等 FedReply。
|
|
7
|
+
*
|
|
8
|
+
* 对外暴露 localhost HTTP:
|
|
9
|
+
* GET /health → { ok, masterId, teamId, hostname, coordEndpoint, connected, pending }
|
|
10
|
+
* GET /fed/directory → 可见 agent 列表
|
|
11
|
+
* POST /fed/ask → { to: "name@hostname", message, from?: "name" } 阻塞等 reply
|
|
12
|
+
*
|
|
13
|
+
* 配置文件 ~/.rotom/link.json: { masterId, hostname, coordEndpoint, teamId }
|
|
14
|
+
* 由 `rotom link join <coordEndpoint>` 一次性生成。
|
|
15
|
+
*/
|
|
16
|
+
import http from "node:http";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import crypto from "node:crypto";
|
|
21
|
+
import { URL } from "node:url";
|
|
22
|
+
import { FedClient } from "../master/federation/client.js";
|
|
23
|
+
import { generateMasterId } from "../master/federation/identity.js";
|
|
24
|
+
import { InMemoryVisibilityStore } from "./visibility-store.js";
|
|
25
|
+
import { PendingRequests } from "./pending-requests.js";
|
|
26
|
+
import { parseAgentRef, formatAgentRef } from "../shared/protocol/federation.js";
|
|
27
|
+
import { createLogger } from "../shared/logger.js";
|
|
28
|
+
const log = createLogger("rotom-link", { stream: "stderr" });
|
|
29
|
+
const DEFAULT_PORT = 28900;
|
|
30
|
+
const LINK_CONFIG_FILE = "link.json";
|
|
31
|
+
function rotomHome() {
|
|
32
|
+
return process.env.ROTOM_HOME || path.join(os.homedir(), ".rotom");
|
|
33
|
+
}
|
|
34
|
+
function linkConfigPath() {
|
|
35
|
+
return path.join(rotomHome(), LINK_CONFIG_FILE);
|
|
36
|
+
}
|
|
37
|
+
export function readLinkConfig() {
|
|
38
|
+
try {
|
|
39
|
+
const raw = JSON.parse(fs.readFileSync(linkConfigPath(), "utf-8"));
|
|
40
|
+
if (raw?.masterId && raw?.coordEndpoint && raw?.teamId) {
|
|
41
|
+
return raw;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch { /* not joined yet */ }
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
function writeLinkConfig(cfg) {
|
|
48
|
+
fs.mkdirSync(rotomHome(), { recursive: true });
|
|
49
|
+
fs.writeFileSync(linkConfigPath(), JSON.stringify(cfg, null, 2) + "\n", "utf-8");
|
|
50
|
+
}
|
|
51
|
+
/** CLI 调:rotom link join <coordEndpoint> --hostname <name> */
|
|
52
|
+
export async function linkJoin(coordEndpoint, hostname) {
|
|
53
|
+
// 1. probe coord /api/identity 拿 teamId(coord 的 masterId)
|
|
54
|
+
const httpUrl = coordEndpoint
|
|
55
|
+
.replace(/^wss:/, "https:")
|
|
56
|
+
.replace(/^ws:/, "http:")
|
|
57
|
+
.replace(/\/$/, "");
|
|
58
|
+
const res = await fetch(`${httpUrl}/api/identity`);
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
throw new Error(`Failed to fetch coord identity: ${res.status} ${res.statusText}`);
|
|
61
|
+
}
|
|
62
|
+
const data = await res.json();
|
|
63
|
+
if (data.role !== "coordination") {
|
|
64
|
+
throw new Error(`Target master is not a coordination master (role=${data.role})`);
|
|
65
|
+
}
|
|
66
|
+
// 2. 生成 masterId(本地持久化)
|
|
67
|
+
const existing = readLinkConfig();
|
|
68
|
+
const masterId = existing?.masterId ?? generateMasterId();
|
|
69
|
+
const cfg = {
|
|
70
|
+
masterId,
|
|
71
|
+
hostname,
|
|
72
|
+
coordEndpoint,
|
|
73
|
+
teamId: data.id,
|
|
74
|
+
teamName: data.teamName ?? `${data.hostname} 团队`,
|
|
75
|
+
};
|
|
76
|
+
writeLinkConfig(cfg);
|
|
77
|
+
log.info(`[rotom-link] joined team ${cfg.teamId} as ${cfg.masterId}/${cfg.hostname}`);
|
|
78
|
+
}
|
|
79
|
+
export async function startLinkServer(opts) {
|
|
80
|
+
const { port, config } = opts;
|
|
81
|
+
// 1. 构造 in-memory db + FedClient
|
|
82
|
+
const store = new InMemoryVisibilityStore();
|
|
83
|
+
const identity = {
|
|
84
|
+
id: config.masterId,
|
|
85
|
+
hostname: config.hostname,
|
|
86
|
+
role: "member",
|
|
87
|
+
teamName: config.teamName ?? "",
|
|
88
|
+
};
|
|
89
|
+
const fedClient = new FedClient(store, {
|
|
90
|
+
identity,
|
|
91
|
+
coordEndpoints: [config.coordEndpoint],
|
|
92
|
+
teamId: config.teamId,
|
|
93
|
+
role: "member",
|
|
94
|
+
});
|
|
95
|
+
const pending = new PendingRequests();
|
|
96
|
+
fedClient.setHandlers({
|
|
97
|
+
deliverLocal: () => false, // link 无本机 agent
|
|
98
|
+
handleReply: (msg) => {
|
|
99
|
+
pending.resolve(msg.requestId, msg.payload.message);
|
|
100
|
+
},
|
|
101
|
+
handleRouteFailed: (msg) => {
|
|
102
|
+
pending.reject(msg.requestId, new Error(`route failed: ${msg.reason}`));
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
fedClient.start();
|
|
106
|
+
// 2. HTTP server
|
|
107
|
+
const server = http.createServer(async (req, res) => {
|
|
108
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
109
|
+
const pathname = url.pathname;
|
|
110
|
+
res.setHeader("Content-Type", "application/json");
|
|
111
|
+
if (req.method === "GET" && pathname === "/health") {
|
|
112
|
+
res.end(JSON.stringify({
|
|
113
|
+
ok: true,
|
|
114
|
+
masterId: config.masterId,
|
|
115
|
+
hostname: config.hostname,
|
|
116
|
+
teamId: config.teamId,
|
|
117
|
+
coordEndpoint: config.coordEndpoint,
|
|
118
|
+
connected: fedClient.isConnected(),
|
|
119
|
+
pending: pending.size(),
|
|
120
|
+
}));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (req.method === "GET" && pathname === "/fed/directory") {
|
|
124
|
+
res.end(JSON.stringify({ ok: true, agents: store.listForHttp(config.teamId) }));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (req.method === "POST" && pathname === "/fed/ask") {
|
|
128
|
+
try {
|
|
129
|
+
const body = await readBody(req);
|
|
130
|
+
const { to, message, from } = body;
|
|
131
|
+
if (!to || !message) {
|
|
132
|
+
res.statusCode = 400;
|
|
133
|
+
res.end(JSON.stringify({ ok: false, error: "to and message are required" }));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!fedClient.isConnected()) {
|
|
137
|
+
res.statusCode = 503;
|
|
138
|
+
res.end(JSON.stringify({ ok: false, error: "fed client not connected to coord" }));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const { name: toName, hostname: toHostname } = parseAgentRef(to);
|
|
142
|
+
if (!toHostname) {
|
|
143
|
+
res.statusCode = 400;
|
|
144
|
+
res.end(JSON.stringify({ ok: false, error: `to must be "name@hostname" (got "${to}")` }));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const toRef = { hostname: toHostname, name: toName };
|
|
148
|
+
// from 用本地 hostname + 指定 name(默认 "link-user")
|
|
149
|
+
const fromName = from ?? "link-user";
|
|
150
|
+
const fromRef = { hostname: config.hostname, name: fromName };
|
|
151
|
+
const requestId = crypto.randomUUID();
|
|
152
|
+
const { promise } = pending.register(requestId);
|
|
153
|
+
const ok = fedClient.route(requestId, fromRef, toRef, { message });
|
|
154
|
+
if (!ok) {
|
|
155
|
+
pending.reject(requestId, new Error("fed route failed (client not connected)"));
|
|
156
|
+
res.statusCode = 503;
|
|
157
|
+
res.end(JSON.stringify({ ok: false, error: "fed route failed" }));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const reply = await promise;
|
|
161
|
+
res.end(JSON.stringify({ ok: true, reply, requestId }));
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
res.statusCode = 500;
|
|
165
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
res.statusCode = 404;
|
|
170
|
+
res.end(JSON.stringify({ ok: false, error: `not found: ${req.method} ${pathname}` }));
|
|
171
|
+
});
|
|
172
|
+
// 3. 启动 + 优雅关闭
|
|
173
|
+
await new Promise((resolve, reject) => {
|
|
174
|
+
server.listen(port, "127.0.0.1", () => resolve());
|
|
175
|
+
server.on("error", reject);
|
|
176
|
+
});
|
|
177
|
+
log.info(`[rotom-link] listening on http://127.0.0.1:${port} (teamId=${config.teamId})`);
|
|
178
|
+
const shutdown = (signal) => {
|
|
179
|
+
log.info(`[rotom-link] received ${signal}, shutting down...`);
|
|
180
|
+
pending.rejectAll(new Error("link daemon shutting down"));
|
|
181
|
+
fedClient.stop();
|
|
182
|
+
server.close(() => process.exit(0));
|
|
183
|
+
setTimeout(() => process.exit(0), 1000).unref();
|
|
184
|
+
};
|
|
185
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
186
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
187
|
+
}
|
|
188
|
+
function readBody(req) {
|
|
189
|
+
return new Promise((resolve, reject) => {
|
|
190
|
+
let buf = "";
|
|
191
|
+
req.on("data", (chunk) => { buf += chunk; });
|
|
192
|
+
req.on("end", () => {
|
|
193
|
+
if (!buf) {
|
|
194
|
+
resolve({});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
resolve(JSON.parse(buf));
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
reject(new Error(`invalid JSON body: ${e.message}`));
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
req.on("error", reject);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
// ── CLI entry: `node dist/link/server.js --port N` ────────────────────────
|
|
208
|
+
async function main() {
|
|
209
|
+
const args = process.argv.slice(2);
|
|
210
|
+
let port = DEFAULT_PORT;
|
|
211
|
+
for (let i = 0; i < args.length; i++) {
|
|
212
|
+
if (args[i] === "--port" && args[i + 1]) {
|
|
213
|
+
port = parseInt(args[i + 1], 10);
|
|
214
|
+
i++;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const config = readLinkConfig();
|
|
218
|
+
if (!config) {
|
|
219
|
+
process.stderr.write(`[rotom-link] no ${linkConfigPath()} found. Run ` +
|
|
220
|
+
`\`rotom link join <coordEndpoint> --hostname <name>\` first.\n`);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
await startLinkServer({ port, config });
|
|
224
|
+
}
|
|
225
|
+
// re-exports for CLI
|
|
226
|
+
export { formatAgentRef };
|
|
227
|
+
// run if invoked directly
|
|
228
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
229
|
+
main().catch((e) => {
|
|
230
|
+
process.stderr.write(`[rotom-link] fatal: ${e.message}\n`);
|
|
231
|
+
process.exit(1);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InMemoryVisibilityStore —— rotom-link 用的内存版 agent_visibility 缓存。
|
|
3
|
+
*
|
|
4
|
+
* FedClient 在 handleDirectorySync 里只调 3 个方法:
|
|
5
|
+
* - removeVisibleAgent(teamId, masterId, name)
|
|
6
|
+
* - listVisibleAgents(teamId)
|
|
7
|
+
* - upsertVisibleAgent({...})
|
|
8
|
+
*
|
|
9
|
+
* 这套结构刚好够 FedClient 缓存协调 master 广播的目录,不需要 better-sqlite3。
|
|
10
|
+
* 通过 `as unknown as MeshDb` 喂给 FedClient(FedClient 不会调其他方法)。
|
|
11
|
+
*/
|
|
12
|
+
export class InMemoryVisibilityStore {
|
|
13
|
+
byTeam = new Map();
|
|
14
|
+
key(masterId, name) {
|
|
15
|
+
return `${masterId}::${name}`;
|
|
16
|
+
}
|
|
17
|
+
listVisibleAgents(teamId) {
|
|
18
|
+
const map = this.byTeam.get(teamId);
|
|
19
|
+
if (!map)
|
|
20
|
+
return [];
|
|
21
|
+
return Array.from(map.values()).map((e) => ({ ...e }));
|
|
22
|
+
}
|
|
23
|
+
upsertVisibleAgent(input) {
|
|
24
|
+
let map = this.byTeam.get(input.team_id);
|
|
25
|
+
if (!map) {
|
|
26
|
+
map = new Map();
|
|
27
|
+
this.byTeam.set(input.team_id, map);
|
|
28
|
+
}
|
|
29
|
+
map.set(this.key(input.master_id, input.agent_name), {
|
|
30
|
+
team_id: input.team_id,
|
|
31
|
+
master_id: input.master_id,
|
|
32
|
+
agent_name: input.agent_name,
|
|
33
|
+
hostname: input.hostname,
|
|
34
|
+
display_name: input.display_name ?? null,
|
|
35
|
+
is_human: input.is_human ? 1 : 0,
|
|
36
|
+
online: input.online ? 1 : 0,
|
|
37
|
+
last_heartbeat: new Date().toISOString(),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
removeVisibleAgent(teamId, masterId, agentName) {
|
|
41
|
+
const map = this.byTeam.get(teamId);
|
|
42
|
+
if (!map)
|
|
43
|
+
return false;
|
|
44
|
+
return map.delete(this.key(masterId, agentName));
|
|
45
|
+
}
|
|
46
|
+
/** 给 /fed/directory HTTP 端点用:返回所有可见 agent(简化展示字段) */
|
|
47
|
+
listForHttp(teamId) {
|
|
48
|
+
return this.listVisibleAgents(teamId).map((v) => ({
|
|
49
|
+
masterId: v.master_id,
|
|
50
|
+
hostname: v.hostname,
|
|
51
|
+
name: v.agent_name,
|
|
52
|
+
displayName: v.display_name ?? undefined,
|
|
53
|
+
isHuman: v.is_human !== 0,
|
|
54
|
+
online: v.online !== 0,
|
|
55
|
+
ref: `${v.agent_name}@${v.hostname}`,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { hashToken } from "../auth.js";
|
|
6
|
+
import { DEFAULT_MASTER_PORT } from "../../shared/constants.js";
|
|
7
|
+
import { createLogger } from "../../shared/logger.js";
|
|
8
|
+
const log = createLogger("mesh-api");
|
|
9
|
+
// avatars 持久化到 ROTOM_HOME 下,避免 /tmp 被清空后 DB 里的 avatar_url 失效。
|
|
10
|
+
const AVATAR_DIR = path.join(process.env.ROTOM_HOME || path.join(os.homedir(), ".rotom"), "avatars");
|
|
11
|
+
const LEGACY_AVATAR_DIR = "/tmp/rotom-avatars";
|
|
12
|
+
// 启动时一次性把 /tmp/rotom-avatars/ 里的旧文件迁到 AVATAR_DIR,避免老数据 404。
|
|
13
|
+
(function migrateLegacyAvatars() {
|
|
14
|
+
try {
|
|
15
|
+
if (!fs.existsSync(LEGACY_AVATAR_DIR))
|
|
16
|
+
return;
|
|
17
|
+
fs.mkdirSync(AVATAR_DIR, { recursive: true });
|
|
18
|
+
for (const name of fs.readdirSync(LEGACY_AVATAR_DIR)) {
|
|
19
|
+
const src = path.join(LEGACY_AVATAR_DIR, name);
|
|
20
|
+
const dst = path.join(AVATAR_DIR, name);
|
|
21
|
+
if (fs.existsSync(dst))
|
|
22
|
+
continue;
|
|
23
|
+
try {
|
|
24
|
+
fs.copyFileSync(src, dst);
|
|
25
|
+
log.info(`avatar migrated: ${name}`);
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
log.warn(`avatar migration failed for ${name}: ${e?.message ?? "unknown"}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
log.warn(`avatar migration scan failed: ${e?.message ?? "unknown"}`);
|
|
34
|
+
}
|
|
35
|
+
})();
|
|
36
|
+
function parseProfile(raw) {
|
|
37
|
+
if (!raw)
|
|
38
|
+
return undefined;
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(raw);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Return the first non-loopback IPv4 address, or "localhost" as fallback. */
|
|
47
|
+
function getLocalIp() {
|
|
48
|
+
const ifaces = os.networkInterfaces();
|
|
49
|
+
for (const entries of Object.values(ifaces)) {
|
|
50
|
+
if (!entries)
|
|
51
|
+
continue;
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (entry.family === "IPv4" && !entry.internal)
|
|
54
|
+
return entry.address;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return "localhost";
|
|
58
|
+
}
|
|
59
|
+
export function registerAgentRoutes(apiRouter, db, auth, hub, serverPort) {
|
|
60
|
+
apiRouter.get("/agents", (_req, res) => {
|
|
61
|
+
const agents = db.listAgents();
|
|
62
|
+
const statsByName = new Map();
|
|
63
|
+
for (const row of db.agentMessageStats()) {
|
|
64
|
+
const name = row.name;
|
|
65
|
+
if (name)
|
|
66
|
+
statsByName.set(name, row);
|
|
67
|
+
}
|
|
68
|
+
// cliTool 不在 DB(那是 worker 运行时属性),从 hub.connections 查 online agent。
|
|
69
|
+
const cliToolByName = hub ? hub.onlineCliTools() : new Map();
|
|
70
|
+
const safe = agents.map((a) => ({
|
|
71
|
+
id: a.id,
|
|
72
|
+
name: a.name,
|
|
73
|
+
description: a.description,
|
|
74
|
+
domain: a.domain,
|
|
75
|
+
status: a.status,
|
|
76
|
+
hostname: a.hostname,
|
|
77
|
+
cliTool: cliToolByName.get(a.name) ?? null,
|
|
78
|
+
enabled: a.enabled !== 0,
|
|
79
|
+
lastHeartbeat: a.last_heartbeat,
|
|
80
|
+
connectedAt: a.connected_at,
|
|
81
|
+
registeredAt: a.registered_at,
|
|
82
|
+
profile: parseProfile(a.profile),
|
|
83
|
+
message_stats: (() => {
|
|
84
|
+
const s = statsByName.get(a.name);
|
|
85
|
+
if (!s)
|
|
86
|
+
return undefined;
|
|
87
|
+
return {
|
|
88
|
+
sent: Number(s.sent) || 0,
|
|
89
|
+
received: Number(s.received) || 0,
|
|
90
|
+
replied: Number(s.replied) || 0,
|
|
91
|
+
failed: Number(s.failed) || 0,
|
|
92
|
+
avg_latency_ms: s.avg_latency_ms == null ? 0 : Number(s.avg_latency_ms),
|
|
93
|
+
};
|
|
94
|
+
})(),
|
|
95
|
+
avatar_url: a.avatar_url,
|
|
96
|
+
}));
|
|
97
|
+
res.json(safe);
|
|
98
|
+
});
|
|
99
|
+
apiRouter.get("/agents/online", (_req, res) => {
|
|
100
|
+
const agents = db.listAgents({ status: "online" });
|
|
101
|
+
const safe = agents.map((a) => ({
|
|
102
|
+
name: a.name,
|
|
103
|
+
domain: a.domain,
|
|
104
|
+
status: a.status,
|
|
105
|
+
}));
|
|
106
|
+
res.json(safe);
|
|
107
|
+
});
|
|
108
|
+
apiRouter.post("/agents", (req, res) => {
|
|
109
|
+
const { name, description, domain, profile } = req.body;
|
|
110
|
+
if (!name || typeof name !== "string") {
|
|
111
|
+
res.status(400).json({ error: "name is required" });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (!domain || typeof domain !== "string") {
|
|
115
|
+
res.status(400).json({ error: "domain is required" });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const domainRow = db.getDomainByName(domain);
|
|
119
|
+
if (!domainRow) {
|
|
120
|
+
res.status(400).json({ error: `Domain "${domain}" does not exist. Create it first.` });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const existing = db.getAgentByName(name);
|
|
124
|
+
if (existing) {
|
|
125
|
+
res.status(409).json({ error: `Agent "${name}" already exists` });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const id = randomUUID();
|
|
129
|
+
const token = auth.generateToken();
|
|
130
|
+
db.insertAgent({
|
|
131
|
+
id,
|
|
132
|
+
name,
|
|
133
|
+
description,
|
|
134
|
+
domain,
|
|
135
|
+
tokenHash: hashToken(token),
|
|
136
|
+
token,
|
|
137
|
+
profile: profile && typeof profile === "object" ? JSON.stringify(profile) : undefined,
|
|
138
|
+
});
|
|
139
|
+
log.info(`Agent registered: "${name}" (domain=${domain})`);
|
|
140
|
+
const port = serverPort ?? DEFAULT_MASTER_PORT;
|
|
141
|
+
const ip = getLocalIp();
|
|
142
|
+
const masterProto = req.secure ? "wss" : "ws";
|
|
143
|
+
res.status(201).json({
|
|
144
|
+
id,
|
|
145
|
+
name,
|
|
146
|
+
token,
|
|
147
|
+
configTemplate: {
|
|
148
|
+
channels: {
|
|
149
|
+
"a2a-gateway": {
|
|
150
|
+
master: `${masterProto}://${ip}:${port}`,
|
|
151
|
+
name,
|
|
152
|
+
token,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
guide: [
|
|
157
|
+
"1. 复制上方 channels 配置到 openclaw.json",
|
|
158
|
+
"2. 启动 OpenClaw Gateway",
|
|
159
|
+
"3. 节点将自动连接 Master",
|
|
160
|
+
],
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
apiRouter.get("/agents/:id", (req, res) => {
|
|
164
|
+
const agent = db.getAgentById(req.params.id);
|
|
165
|
+
if (!agent) {
|
|
166
|
+
res.status(404).json({ error: "Agent not found" });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
res.json({
|
|
170
|
+
id: agent.id,
|
|
171
|
+
name: agent.name,
|
|
172
|
+
description: agent.description,
|
|
173
|
+
domain: agent.domain,
|
|
174
|
+
status: agent.status,
|
|
175
|
+
hostname: agent.hostname,
|
|
176
|
+
instanceId: agent.instance_id,
|
|
177
|
+
platform: agent.platform,
|
|
178
|
+
endpoint: agent.endpoint,
|
|
179
|
+
enabled: agent.enabled !== 0,
|
|
180
|
+
lastHeartbeat: agent.last_heartbeat,
|
|
181
|
+
connectedAt: agent.connected_at,
|
|
182
|
+
registeredAt: agent.registered_at,
|
|
183
|
+
profile: parseProfile(agent.profile),
|
|
184
|
+
avatar_url: agent.avatar_url,
|
|
185
|
+
token: agent.token,
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
apiRouter.post("/agents/:id/refresh-token", (req, res) => {
|
|
189
|
+
const agent = db.getAgentById(req.params.id);
|
|
190
|
+
if (!agent) {
|
|
191
|
+
res.status(404).json({ error: "Agent not found" });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const newToken = auth.generateToken();
|
|
195
|
+
db.updateAgentToken(agent.id, hashToken(newToken), newToken);
|
|
196
|
+
log.warn(`Token refreshed for agent "${agent.name}" (id=${agent.id})`);
|
|
197
|
+
res.json({ token: newToken });
|
|
198
|
+
});
|
|
199
|
+
apiRouter.put("/agents/:id", (req, res) => {
|
|
200
|
+
const agent = db.getAgentById(req.params.id);
|
|
201
|
+
if (!agent) {
|
|
202
|
+
res.status(404).json({ error: "Agent not found" });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const { name, description, domain, enabled, profile, avatar_url } = req.body;
|
|
206
|
+
if (name !== undefined) {
|
|
207
|
+
if (!name || typeof name !== "string" || !name.trim()) {
|
|
208
|
+
res.status(400).json({ error: "name cannot be empty" });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const existing = db.getAgentByName(name.trim());
|
|
212
|
+
if (existing && existing.id !== agent.id) {
|
|
213
|
+
res.status(409).json({ error: `Name "${name}" is already taken` });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
db.updateAgentName(agent.id, name.trim());
|
|
217
|
+
}
|
|
218
|
+
if (domain !== undefined) {
|
|
219
|
+
if (!domain || typeof domain !== "string") {
|
|
220
|
+
res.status(400).json({ error: "domain cannot be empty" });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const domainRow = db.getDomainByName(domain);
|
|
224
|
+
if (!domainRow) {
|
|
225
|
+
res.status(400).json({ error: `Domain "${domain}" does not exist` });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (description !== undefined || domain !== undefined || profile !== undefined || avatar_url !== undefined) {
|
|
230
|
+
db.updateAgentMeta(agent.id, {
|
|
231
|
+
description,
|
|
232
|
+
domain,
|
|
233
|
+
profile: profile !== undefined ? JSON.stringify(profile) : undefined,
|
|
234
|
+
avatar_url,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (enabled !== undefined) {
|
|
238
|
+
db.updateAgentEnabled(agent.id, !!enabled);
|
|
239
|
+
}
|
|
240
|
+
if (hub) {
|
|
241
|
+
if (domain !== undefined || enabled !== undefined) {
|
|
242
|
+
hub.pushConfigUpdate(agent.id, {
|
|
243
|
+
domain: domain ?? agent.domain ?? undefined,
|
|
244
|
+
enabled: enabled !== undefined ? !!enabled : agent.enabled !== 0,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
hub.broadcastAgentUpdate(agent.id);
|
|
248
|
+
}
|
|
249
|
+
res.json({ ok: true });
|
|
250
|
+
});
|
|
251
|
+
apiRouter.post("/agents/avatar", (req, res) => {
|
|
252
|
+
const { agentId, dataBase64, mimeType } = req.body || {};
|
|
253
|
+
if (typeof agentId !== "string" || !agentId.trim()) {
|
|
254
|
+
res.status(400).json({ error: "agentId is required" });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const agent = db.getAgentById(agentId);
|
|
258
|
+
if (!agent) {
|
|
259
|
+
res.status(404).json({ error: "Agent not found" });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (typeof dataBase64 !== "string" || !dataBase64) {
|
|
263
|
+
res.status(400).json({ error: "dataBase64 is required" });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
let buf;
|
|
267
|
+
try {
|
|
268
|
+
buf = Buffer.from(dataBase64, "base64");
|
|
269
|
+
}
|
|
270
|
+
catch (e) {
|
|
271
|
+
res.status(400).json({ error: `base64 decode failed: ${e?.message ?? "unknown"}` });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (buf.length > 2 * 1024 * 1024) {
|
|
275
|
+
res.status(400).json({ error: "avatar too large: max 2MB" });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const allowedMimes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
279
|
+
const actualMime = mimeType && allowedMimes.includes(mimeType) ? mimeType : "image/png";
|
|
280
|
+
const ext = actualMime.includes("png") ? "png"
|
|
281
|
+
: actualMime.includes("gif") ? "gif"
|
|
282
|
+
: actualMime.includes("webp") ? "webp" : "jpg";
|
|
283
|
+
const rand = randomUUID().replace(/-/g, "").slice(0, 6);
|
|
284
|
+
fs.mkdirSync(AVATAR_DIR, { recursive: true });
|
|
285
|
+
const stamp = Date.now();
|
|
286
|
+
const storedName = `${agentId}-${stamp}-${rand}.${ext}`;
|
|
287
|
+
const absPath = path.join(AVATAR_DIR, storedName);
|
|
288
|
+
fs.writeFileSync(absPath, buf);
|
|
289
|
+
const url = `/api/avatars/${storedName}`;
|
|
290
|
+
db.updateAgentMeta(agent.id, { avatar_url: url });
|
|
291
|
+
log.info(`avatar saved for agent ${agent.name}: ${url}`);
|
|
292
|
+
res.status(201).json({ url });
|
|
293
|
+
});
|
|
294
|
+
apiRouter.get("/avatars/:filename", (req, res) => {
|
|
295
|
+
const { filename } = req.params;
|
|
296
|
+
if (!filename || filename.includes("/") || filename.includes("\\")) {
|
|
297
|
+
res.status(404).json({ error: "not found" });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
|
301
|
+
const mimeMap = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" };
|
|
302
|
+
const tryServe = (dir) => {
|
|
303
|
+
const abs = path.resolve(dir, filename);
|
|
304
|
+
if (!abs.startsWith(path.resolve(dir) + path.sep))
|
|
305
|
+
return false;
|
|
306
|
+
if (!fs.existsSync(abs))
|
|
307
|
+
return false;
|
|
308
|
+
res.setHeader("Content-Type", mimeMap[ext] ?? "application/octet-stream");
|
|
309
|
+
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
310
|
+
fs.createReadStream(abs).pipe(res);
|
|
311
|
+
return true;
|
|
312
|
+
};
|
|
313
|
+
if (tryServe(AVATAR_DIR))
|
|
314
|
+
return;
|
|
315
|
+
if (tryServe(LEGACY_AVATAR_DIR))
|
|
316
|
+
return;
|
|
317
|
+
res.status(404).json({ error: "not found" });
|
|
318
|
+
});
|
|
319
|
+
apiRouter.delete("/agents/:id", (req, res) => {
|
|
320
|
+
const agent = db.getAgentById(req.params.id);
|
|
321
|
+
if (!agent) {
|
|
322
|
+
res.status(404).json({ error: "Agent not found" });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (agent.status === "online") {
|
|
326
|
+
res.status(400).json({ error: "Cannot delete an online agent. Disconnect it first." });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
db.deleteAgent(agent.id);
|
|
330
|
+
log.warn(`Agent deleted: "${agent.name}" (id=${agent.id})`);
|
|
331
|
+
res.json({ ok: true });
|
|
332
|
+
});
|
|
333
|
+
}
|