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.
@@ -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 };