dsh-a2a-groups 0.0.0-stage → 0.1.2
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-ui.md +350 -0
- package/README.md +160 -2
- package/bin/agent-groups.js +438 -0
- package/client.js +3782 -0
- package/cordis.patch.yml +17 -0
- package/docs/ARCHITECTURE.md +281 -0
- package/docs/BACKLOG.md +201 -0
- package/docs/CONTRACT.md +592 -0
- package/docs/DEPLOY.md +645 -0
- package/docs/DESIGN.md +390 -0
- package/docs/MCP.md +260 -0
- package/docs/PRODUCT.md +186 -0
- package/docs/RUNBOOK.md +406 -0
- package/lib/core/bridge.js +1167 -0
- package/lib/core/card.js +217 -0
- package/lib/core/colleague-cards.js +149 -0
- package/lib/core/config.js +235 -0
- package/lib/core/endpoint.js +69 -0
- package/lib/core/groups.js +3010 -0
- package/lib/core/invite.js +412 -0
- package/lib/core/memberships.js +366 -0
- package/lib/core/probe.js +219 -0
- package/lib/core/registry.js +344 -0
- package/lib/core/server.js +1304 -0
- package/lib/core/skills-group.js +1164 -0
- package/lib/core/skills-session.js +116 -0
- package/lib/core/skills.js +76 -0
- package/lib/core/wire.js +772 -0
- package/lib/dsh/deliver.js +601 -0
- package/lib/dsh/inbox.js +658 -0
- package/lib/dsh/profile.js +195 -0
- package/lib/dsh/prompt.js +55 -0
- package/lib/dsh/sessions.js +278 -0
- package/lib/dsh/toolkit.js +166 -0
- package/lib/dsh/tools.js +101 -0
- package/lib/index.js +648 -0
- package/lib/mcp/mcp-deps.js +145 -0
- package/lib/mcp-server.js +261 -0
- package/lib/shared/tool-runtime.js +1349 -0
- package/lib/shared/tool-specs.js +1123 -0
- package/lib/types/client.d.ts +144 -0
- package/package.json +84 -4
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* agent-groups — a standalone A2A group host.
|
|
4
|
+
*
|
|
5
|
+
* WHAT THIS IS
|
|
6
|
+
*
|
|
7
|
+
* A group host is a server that holds collaboration groups and speaks A2A so that
|
|
8
|
+
* people's DSH instances can join them, post into them and read from them. It is
|
|
9
|
+
* deliberately NOT a DSH instance:
|
|
10
|
+
*
|
|
11
|
+
* - it runs no model, so it cannot think, and it never acts on a message;
|
|
12
|
+
* - it has no Sessions, so there is nothing to deliver into;
|
|
13
|
+
* - it is a plain Node program you can leave running on a server for months.
|
|
14
|
+
*
|
|
15
|
+
* Earlier this role was played by a DSH instance with the chat half disabled. That
|
|
16
|
+
* worked, but it was the wrong shape: it made a stateful server depend on a desktop
|
|
17
|
+
* application, put the host's logs inside somebody's DSH log, and — the symptom that
|
|
18
|
+
* started this rewrite — forced the host to pretend it had a Session id so that
|
|
19
|
+
* membership records could be keyed.
|
|
20
|
+
*
|
|
21
|
+
* The host has NO membership of its own groups. Members hold memberships; the host
|
|
22
|
+
* holds groups. That is why nothing here writes to a membership ledger.
|
|
23
|
+
*
|
|
24
|
+
* HOW TO RUN
|
|
25
|
+
*
|
|
26
|
+
* node bin/agent-groups.js --port 3095 --data /var/lib/agent-groups
|
|
27
|
+
* node bin/agent-groups.js --port 3095 --data ./data --token "$(openssl rand -hex 32)"
|
|
28
|
+
* AGENT_GROUPS_TOKEN=... node bin/agent-groups.js --port 3095 --data ./data
|
|
29
|
+
*
|
|
30
|
+
* It prints, on startup, the one line an operator needs: the agent card URL and the
|
|
31
|
+
* AISIX entry that makes it reachable. Nothing else is required to deploy it —
|
|
32
|
+
* behind a gateway it needs no TLS of its own, and in front of one it does.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
36
|
+
import { mkdirSync, readFileSync, existsSync } from 'node:fs';
|
|
37
|
+
import { dirname, join, resolve } from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
|
|
40
|
+
import { normalizeConfig } from '../lib/core/config.js';
|
|
41
|
+
import { createGroupStore } from '../lib/core/groups.js';
|
|
42
|
+
import { createSkillRegistry } from '../lib/core/skills.js';
|
|
43
|
+
import { createGroupSkills } from '../lib/core/skills-group.js';
|
|
44
|
+
import { createServer } from '../lib/core/server.js';
|
|
45
|
+
import { TaskStore } from '../lib/core/wire.js';
|
|
46
|
+
|
|
47
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
const VERSION = readVersion();
|
|
49
|
+
|
|
50
|
+
/** Print usage and exit. */
|
|
51
|
+
function usage() {
|
|
52
|
+
process.stdout.write(`agent-groups ${VERSION} — an A2A collaboration group host
|
|
53
|
+
|
|
54
|
+
Usage: agent-groups [options]
|
|
55
|
+
|
|
56
|
+
--port <n> port to listen on (default 3095, or $AGENT_GROUPS_PORT)
|
|
57
|
+
--host <addr> address to bind (default 127.0.0.1, or $AGENT_GROUPS_HOST)
|
|
58
|
+
--data <dir> data directory (default ./data, or $AGENT_GROUPS_DATA)
|
|
59
|
+
--token <secret> bearer token for /a2a (default $AGENT_GROUPS_TOKEN, else none)
|
|
60
|
+
--agent <name> this host's agent name inside the gateway, for invite links
|
|
61
|
+
--gateway <url> gateway base for invite links, e.g. http://gw:3000/a2a
|
|
62
|
+
--forward-proto scheme the gateway should advertise (http or https)
|
|
63
|
+
--public-url <u> absolute URL peers should use, when not behind a gateway
|
|
64
|
+
--help print this message
|
|
65
|
+
--version print the version
|
|
66
|
+
|
|
67
|
+
Environment variables mirror the flags (AGENT_GROUPS_PORT, _HOST, _DATA, _TOKEN,
|
|
68
|
+
_AGENT, _GATEWAY, _FORWARD_PROTO, _PUBLIC_URL). Flags win over the environment.
|
|
69
|
+
|
|
70
|
+
The data directory holds groups.json and must persist across restarts. Back it up:
|
|
71
|
+
it contains every group's feed, and nothing else can reconstruct that.
|
|
72
|
+
`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Read the package version without importing JSON (keeps the loader simple). */
|
|
76
|
+
function readVersion() {
|
|
77
|
+
try {
|
|
78
|
+
const packagePath = join(HERE, '..', 'package.json');
|
|
79
|
+
return JSON.parse(readFileSync(packagePath, 'utf8')).version ?? '0.0.0';
|
|
80
|
+
} catch {
|
|
81
|
+
return '0.0.0';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parse `--flag value` / `--flag` / `--flag=value`.
|
|
87
|
+
*
|
|
88
|
+
* Hand-rolled rather than `node:util.parseArgs` because this program has to keep its
|
|
89
|
+
* dependency surface at zero for a server install, and `parseArgs` cannot express
|
|
90
|
+
* "flag wins over env wins over default" as directly as the small table below.
|
|
91
|
+
*
|
|
92
|
+
* @param {string[]} argv - `process.argv.slice(2)`.
|
|
93
|
+
* @returns {Record<string, string|boolean>} parsed flags.
|
|
94
|
+
*/
|
|
95
|
+
function parseFlags(argv) {
|
|
96
|
+
const flags = {};
|
|
97
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
98
|
+
const token = argv[index];
|
|
99
|
+
if (!token.startsWith('--')) throw new Error(`unexpected argument: ${token}`);
|
|
100
|
+
const eq = token.indexOf('=');
|
|
101
|
+
if (eq > 0) {
|
|
102
|
+
flags[token.slice(2, eq)] = token.slice(eq + 1);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const name = token.slice(2);
|
|
106
|
+
const next = argv[index + 1];
|
|
107
|
+
if (next === undefined || next.startsWith('--')) {
|
|
108
|
+
flags[name] = true;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
flags[name] = next;
|
|
112
|
+
index += 1;
|
|
113
|
+
}
|
|
114
|
+
return flags;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** First defined value, treated as text. Empty strings count as absent. */
|
|
118
|
+
function pick(...values) {
|
|
119
|
+
for (const value of values) {
|
|
120
|
+
if (typeof value === 'string' && value.trim() !== '') return value.trim();
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A route registrar over a plain `http.Server`.
|
|
127
|
+
*
|
|
128
|
+
* The server facade asks for `{kind, path, handler}` registrations and expects a
|
|
129
|
+
* disposer back. On a bare Node server there is nothing to unregister, so the
|
|
130
|
+
* disposer removes the entry from a table the request handler consults — that keeps
|
|
131
|
+
* `detach()` honest instead of pretending to clean up.
|
|
132
|
+
*
|
|
133
|
+
* @returns {{registrar: {register: (route: {kind: string, path: string, handler: Function}) => (() => void)}, handle: Function, routes: () => object[]}} the adapter.
|
|
134
|
+
*/
|
|
135
|
+
function createHttpRegistrar(logger) {
|
|
136
|
+
/** @type {Array<{kind: string, path: string, handler: Function}>} */
|
|
137
|
+
const routes = [];
|
|
138
|
+
|
|
139
|
+
const registrar = {
|
|
140
|
+
register({ kind, path, handler }) {
|
|
141
|
+
if (typeof path !== 'string' || !path.startsWith('/')) {
|
|
142
|
+
throw new Error(`agent-groups: a route path must start with "/" (got ${String(path)})`);
|
|
143
|
+
}
|
|
144
|
+
if (typeof handler !== 'function') throw new Error('agent-groups: a route handler must be a function');
|
|
145
|
+
const route = { kind: kind === 'prefix' ? 'prefix' : 'exact', path, handler };
|
|
146
|
+
routes.push(route);
|
|
147
|
+
return () => {
|
|
148
|
+
const at = routes.indexOf(route);
|
|
149
|
+
if (at >= 0) routes.splice(at, 1);
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Dispatch one request.
|
|
156
|
+
*
|
|
157
|
+
* Exact matches win over prefix matches, and among prefixes the longest wins — the
|
|
158
|
+
* same rule the DSH webserver applies, repeated here so the A2A surface behaves
|
|
159
|
+
* identically whether the host is embedded or standalone.
|
|
160
|
+
*/
|
|
161
|
+
function handle(request, response) {
|
|
162
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
163
|
+
const pathname = url.pathname;
|
|
164
|
+
let chosen;
|
|
165
|
+
for (const route of routes) {
|
|
166
|
+
if (route.kind === 'exact' && route.path === pathname) {
|
|
167
|
+
chosen = route;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (chosen === undefined) {
|
|
172
|
+
let bestLength = -1;
|
|
173
|
+
for (const route of routes) {
|
|
174
|
+
if (route.kind !== 'prefix') continue;
|
|
175
|
+
if (!pathname.startsWith(route.path)) continue;
|
|
176
|
+
if (route.path.length > bestLength) {
|
|
177
|
+
bestLength = route.path.length;
|
|
178
|
+
chosen = route;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (chosen === undefined) {
|
|
183
|
+
// No SPA shell here, unlike DSH: a 404 with a JSON body is the honest answer for
|
|
184
|
+
// a host that serves nothing but A2A.
|
|
185
|
+
response.writeHead(404, { 'content-type': 'application/json; charset=utf-8' });
|
|
186
|
+
response.end(JSON.stringify({ error: 'not_found', path: pathname }));
|
|
187
|
+
logger.debug?.(`agent-groups: 404 ${request.method} ${pathname}`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const result = chosen.handler(request, response);
|
|
192
|
+
// A handler may be async; an unhandled rejection would kill the process.
|
|
193
|
+
if (result !== null && typeof result === 'object' && typeof result.catch === 'function') {
|
|
194
|
+
result.catch((error) => {
|
|
195
|
+
logger.warn?.(`agent-groups: handler failed for ${pathname}: ${error?.message ?? error}`);
|
|
196
|
+
if (!response.headersSent) {
|
|
197
|
+
response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
|
|
198
|
+
response.end(JSON.stringify({ error: 'internal_error' }));
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
} catch (error) {
|
|
203
|
+
logger.warn?.(`agent-groups: handler threw for ${pathname}: ${error?.message ?? error}`);
|
|
204
|
+
if (!response.headersSent) {
|
|
205
|
+
response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
|
|
206
|
+
response.end(JSON.stringify({ error: 'internal_error' }));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { registrar, handle, routes: () => [...routes] };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Log one line to stdout, timestamped: a server's log is read after the fact. */
|
|
215
|
+
function createLogger(verbose) {
|
|
216
|
+
const stamp = () => new Date().toISOString();
|
|
217
|
+
const emit = (level, line) => {
|
|
218
|
+
process.stdout.write(`${stamp()} ${level} ${line}\n`);
|
|
219
|
+
};
|
|
220
|
+
return {
|
|
221
|
+
info: (line) => emit('info ', line),
|
|
222
|
+
warn: (line) => emit('warn ', line),
|
|
223
|
+
// Debug output is per-request noise; only shown with AGENT_GROUPS_VERBOSE=1.
|
|
224
|
+
debug: (line) => {
|
|
225
|
+
if (verbose) emit('debug', line);
|
|
226
|
+
},
|
|
227
|
+
error: (line) => emit('error', line),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Exit with a message an operator can act on, rather than a stack trace. */
|
|
232
|
+
function fail(message, code = 1) {
|
|
233
|
+
process.stderr.write(`agent-groups: ${message}\n`);
|
|
234
|
+
process.exit(code);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function main() {
|
|
238
|
+
let flags;
|
|
239
|
+
try {
|
|
240
|
+
flags = parseFlags(process.argv.slice(2));
|
|
241
|
+
} catch (error) {
|
|
242
|
+
fail(error instanceof Error ? error.message : String(error), 2);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (flags.help === true) {
|
|
246
|
+
usage();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (flags.version === true) {
|
|
250
|
+
process.stdout.write(`${VERSION}\n`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const port = Number(pick(flags.port, process.env.AGENT_GROUPS_PORT) ?? 3095);
|
|
255
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) fail(`invalid --port: ${String(flags.port)}`, 2);
|
|
256
|
+
const host = pick(flags.host, process.env.AGENT_GROUPS_HOST) ?? '127.0.0.1';
|
|
257
|
+
const dataDir = resolve(pick(flags.data, process.env.AGENT_GROUPS_DATA) ?? 'data');
|
|
258
|
+
const token = pick(flags.token, process.env.AGENT_GROUPS_TOKEN);
|
|
259
|
+
// `agent-group-host` is the HOST's name at the gateway: one entry serving every group, because a
|
|
260
|
+
// group is data addressed by `groupId` inside the payload. Member instances are named
|
|
261
|
+
// `agent-person-<who>` instead — one entry each, because each is a separate process on a
|
|
262
|
+
// separate machine and one gateway path can only forward to one address.
|
|
263
|
+
const agentName = pick(flags.agent, process.env.AGENT_GROUPS_AGENT) ?? 'agent-group-host';
|
|
264
|
+
const gatewayBase = pick(flags.gateway, process.env.AGENT_GROUPS_GATEWAY);
|
|
265
|
+
const forwardProto = pick(flags['forward-proto'], process.env.AGENT_GROUPS_FORWARD_PROTO);
|
|
266
|
+
const publicUrl = pick(flags['public-url'], process.env.AGENT_GROUPS_PUBLIC_URL);
|
|
267
|
+
const verbose = process.env.AGENT_GROUPS_VERBOSE === '1';
|
|
268
|
+
const logger = createLogger(verbose);
|
|
269
|
+
|
|
270
|
+
// Fail before doing any work if the data directory is unusable: a host that starts
|
|
271
|
+
// and then cannot persist is worse than one that refuses to start.
|
|
272
|
+
try {
|
|
273
|
+
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
274
|
+
} catch (error) {
|
|
275
|
+
fail(`cannot use --data ${dataDir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const config = normalizeConfig({
|
|
280
|
+
agent: {
|
|
281
|
+
name: agentName,
|
|
282
|
+
description: '协作群宿主:持有协作群,供同事的 DSH 实例通过 A2A 加入、发言与订阅。',
|
|
283
|
+
},
|
|
284
|
+
groups: {
|
|
285
|
+
// The host holds groups; the membership ledger is for MEMBERS. Nothing in this
|
|
286
|
+
// program writes to one, which is why `home` points at the data directory: the
|
|
287
|
+
// store still needs somewhere to keep its own file.
|
|
288
|
+
gatewayBase: gatewayBase ?? '',
|
|
289
|
+
agentName,
|
|
290
|
+
home: dataDir,
|
|
291
|
+
},
|
|
292
|
+
server: {
|
|
293
|
+
token: token ?? '',
|
|
294
|
+
bindSessionId: 'self',
|
|
295
|
+
},
|
|
296
|
+
client: {
|
|
297
|
+
forwardProto: forwardProto ?? '',
|
|
298
|
+
},
|
|
299
|
+
delivery: { enabled: false },
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const store = createGroupStore({
|
|
303
|
+
home: dataDir,
|
|
304
|
+
logger,
|
|
305
|
+
config,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const registry = createSkillRegistry();
|
|
309
|
+
for (const skill of createGroupSkills({
|
|
310
|
+
store,
|
|
311
|
+
// A host has no memberships of its own. The ledger is never read here; passing a
|
|
312
|
+
// stub that refuses loudly stops a future edit from quietly reintroducing the
|
|
313
|
+
// "host needs a Session" mistake this rewrite removed.
|
|
314
|
+
identities: {
|
|
315
|
+
// The host holds no memberships of its own — members do. But the skill bodies
|
|
316
|
+
// are shared with the member build, and `group/create`/`group/join`/`group/claim`
|
|
317
|
+
// each record "the caller became a member here". On a host that intent is
|
|
318
|
+
// satisfied by the group itself (the owner IS a member of the group it created),
|
|
319
|
+
// so the write is a no-op rather than an error.
|
|
320
|
+
//
|
|
321
|
+
// It used to throw. That was wrong in a subtle way: it made a shared code path
|
|
322
|
+
// unusable, which pushed the host toward having fake memberships (the
|
|
323
|
+
// `HOST_SESSION_ID` mistake this rewrite removes). Returning silently keeps the
|
|
324
|
+
// host's ledger empty without pretending.
|
|
325
|
+
upsert: () => {},
|
|
326
|
+
get: () => undefined,
|
|
327
|
+
list: () => [],
|
|
328
|
+
has: () => false,
|
|
329
|
+
flush: () => {},
|
|
330
|
+
},
|
|
331
|
+
config,
|
|
332
|
+
logger,
|
|
333
|
+
clock: () => Date.now(),
|
|
334
|
+
})) {
|
|
335
|
+
registry.register(skill);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const tasks = new TaskStore({
|
|
339
|
+
ttlMs: config.server.taskTimeoutMs * 4,
|
|
340
|
+
persistPath: null,
|
|
341
|
+
clock: () => Date.now(),
|
|
342
|
+
});
|
|
343
|
+
const { registrar, handle, routes } = createHttpRegistrar(logger);
|
|
344
|
+
const server = createServer({
|
|
345
|
+
registrar,
|
|
346
|
+
where: 'standalone agent-groups',
|
|
347
|
+
logger,
|
|
348
|
+
config,
|
|
349
|
+
// The host never delivers: nobody is on the other end. Kept as a stub because the
|
|
350
|
+
// facade asks for one, and a loud failure beats a silent no-op if that changes.
|
|
351
|
+
deliverer: {
|
|
352
|
+
deliver: async () => {
|
|
353
|
+
throw new Error('agent-groups: a group host has no Sessions to deliver into; this is a bug');
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
tasks,
|
|
357
|
+
skills: registry,
|
|
358
|
+
profileProvider: undefined,
|
|
359
|
+
// Lets an operator force a flush over HTTP before copying groups.json.
|
|
360
|
+
onFlush: () => store.flush(),
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
server.attach();
|
|
364
|
+
|
|
365
|
+
const httpServer = createHttpServer(handle);
|
|
366
|
+
httpServer.on('clientError', (error, socket) => {
|
|
367
|
+
logger.warn(`agent-groups: client error ${error?.message ?? error}`);
|
|
368
|
+
if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
await new Promise((settle, reject) => {
|
|
372
|
+
httpServer.once('error', reject);
|
|
373
|
+
httpServer.listen(port, host, settle);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const actualPort = httpServer.address().port;
|
|
377
|
+
const shown = publicUrl ?? `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${actualPort}`;
|
|
378
|
+
const groups = store.list().length;
|
|
379
|
+
|
|
380
|
+
logger.info(`agent-groups ${VERSION} listening on ${host}:${actualPort} (data: ${dataDir})`);
|
|
381
|
+
logger.info(`agent card: ${shown}/a2a/.well-known/agent-card.json`);
|
|
382
|
+
logger.info(`${groups} group(s) hosted`);
|
|
383
|
+
if (token === undefined) {
|
|
384
|
+
logger.warn('no token set: every caller is treated as a local, trusted client. Set --token before exposing this host.');
|
|
385
|
+
}
|
|
386
|
+
if (gatewayBase === undefined) {
|
|
387
|
+
logger.warn('no --gateway set: invite links cannot be generated until you pass it (or set it later in the config).');
|
|
388
|
+
} else {
|
|
389
|
+
logger.info(`invite links will point at ${gatewayBase}/${agentName}`);
|
|
390
|
+
logger.info(`AISIX entry (one per host, not per group) — resources.yaml:`);
|
|
391
|
+
logger.info(` - name: ${agentName}`);
|
|
392
|
+
logger.info(` url: ${shown}/a2a`);
|
|
393
|
+
}
|
|
394
|
+
logger.info(`routes: ${routes().map((route) => `${route.kind === 'prefix' ? '' : ''}${route.path}`).join(', ')}`);
|
|
395
|
+
|
|
396
|
+
/** Shut down in the order that loses no data: stop accepting, flush, exit. */
|
|
397
|
+
let stopping = false;
|
|
398
|
+
const stop = async (signal) => {
|
|
399
|
+
if (stopping) return;
|
|
400
|
+
stopping = true;
|
|
401
|
+
logger.info(`${signal} received; flushing group data and stopping`);
|
|
402
|
+
try {
|
|
403
|
+
// A flush failure must not turn a clean shutdown into a crash: the in-memory
|
|
404
|
+
// state is already correct and the next start will retry the write.
|
|
405
|
+
await store.flush();
|
|
406
|
+
server.detach();
|
|
407
|
+
await new Promise((settle) => httpServer.close(settle));
|
|
408
|
+
} catch (error) {
|
|
409
|
+
logger.warn(`shutdown problem: ${error instanceof Error ? error.message : String(error)}`);
|
|
410
|
+
}
|
|
411
|
+
process.exit(0);
|
|
412
|
+
};
|
|
413
|
+
process.on('SIGINT', () => void stop('SIGINT'));
|
|
414
|
+
process.on('SIGTERM', () => void stop('SIGTERM'));
|
|
415
|
+
|
|
416
|
+
// A server must not die on a stray rejection: log it and keep serving. Anything that
|
|
417
|
+
// genuinely cannot continue will fail on the next request anyway, with a message
|
|
418
|
+
// tied to that request rather than to nothing.
|
|
419
|
+
process.on('unhandledRejection', (reason) => {
|
|
420
|
+
logger.warn(`unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
|
|
421
|
+
});
|
|
422
|
+
process.on('uncaughtException', (error) => {
|
|
423
|
+
logger.warn(`uncaught exception: ${error instanceof Error ? error.message : String(error)}`);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Only run when invoked directly; importing this file must have no side effects
|
|
428
|
+
// (a test wants to import `createHttpRegistrar`).
|
|
429
|
+
const invokedDirectly = process.argv[1] !== undefined
|
|
430
|
+
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
|
431
|
+
|
|
432
|
+
if (invokedDirectly) {
|
|
433
|
+
main().catch((error) => {
|
|
434
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export { createHttpRegistrar, parseFlags, usage };
|