@ai_mind/dsh-a2a 0.0.0-stage → 1.1.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/lib/server.js ADDED
@@ -0,0 +1,656 @@
1
+ /**
2
+ * A2A server assembly: Agent Card, transports, authentication and DSH routes.
3
+ *
4
+ * The official `@a2a-js/sdk` supplies the protocol engine — task lifecycle,
5
+ * JSON-RPC and HTTP+JSON transports, SSE framing, ProtoJSON codecs, push
6
+ * notifications and Agent Card signing.
7
+ *
8
+ * Two listening modes are supported:
9
+ * - shared (default): the A2A routes live on the Harness Web server, so the
10
+ * endpoint uses the GUI's port and lifecycle;
11
+ * - standalone (`server.port` set): a dedicated `node:http` listener owns the
12
+ * card and A2A routes on its own host/port, while the Harness Web server
13
+ * keeps only the public card mirror and the authenticated settings panel.
14
+ *
15
+ * @module @ai_mind/dsh-a2a/server
16
+ */
17
+
18
+ import http from 'node:http'
19
+ import { isIP } from 'node:net'
20
+ import express from 'express'
21
+ import { A2A_PROTOCOL_VERSION } from '@a2a-js/sdk'
22
+ import {
23
+ DefaultPushNotificationSender,
24
+ DefaultRequestHandler,
25
+ InMemoryPushNotificationStore,
26
+ } from '@a2a-js/sdk/server'
27
+ import { agentCardHandler, jsonRpcHandler, restHandler } from '@a2a-js/sdk/server/express'
28
+ import { buildAgentCard, buildExtendedCard, createCardSigner, isAuthEnabled } from './card.js'
29
+ import { createAuthenticator } from './auth.js'
30
+ import { createPanelRouter } from './panel.js'
31
+ import { A2ATaskStore, GuardedPushNotificationStore } from './store.js'
32
+ import { HarnessAgentExecutor } from './executor.js'
33
+ import { AgentRuntime } from './bridge.js'
34
+ import { errorMessage, trimTrailingSlash } from './util.js'
35
+ import { sweepAttachmentDirectories } from './files.js'
36
+ import { resolveCredentials } from './config.js'
37
+
38
+ const JSON_CONTENT_TYPE = 'application/json'
39
+ const A2A_CONTENT_TYPE = 'application/a2a+json'
40
+
41
+ /** Minimal structured logger over the Cordis logger. */
42
+ function createLogger(ctx) {
43
+ const render = (args) => ['[a2a]', ...args.map((value) => (typeof value === 'string' ? value : errorMessage(value)))].join(' ')
44
+ const emit = (level, args) => {
45
+ try {
46
+ const logger = ctx.logger
47
+ if (typeof logger?.[level] === 'function') logger[level](render(args))
48
+ else if (typeof logger?.log === 'function') logger.log(render(args))
49
+ } catch {
50
+ /* logging must never fail a request */
51
+ }
52
+ }
53
+ return {
54
+ info: (...args) => emit('info', args),
55
+ warn: (...args) => emit('warn', args),
56
+ error: (...args) => emit('error', args),
57
+ }
58
+ }
59
+
60
+ /** Write an IPv6 host in URL form. */
61
+ function formatUrlHost(host) {
62
+ return isIP(host) === 6 ? `[${host}]` : host
63
+ }
64
+
65
+ /**
66
+ * Derive the externally visible origin.
67
+ *
68
+ * `server.publicUrl` always wins. A configured `server.port` selects the
69
+ * standalone listener's own origin; otherwise the Harness Web server's origin
70
+ * is used.
71
+ */
72
+ function resolvePublicBase(config, webServer, logger) {
73
+ if (config.server.publicUrl) return trimTrailingSlash(config.server.publicUrl)
74
+ if (config.server.port !== null) {
75
+ const host = config.server.host
76
+ if (host === '0.0.0.0' || host === '::') {
77
+ logger.warn(
78
+ `[a2a] server.host ${host} is a wildcard; advertising a loopback URL. ` +
79
+ 'Set server.publicUrl to the address remote A2A clients should use.',
80
+ )
81
+ }
82
+ const advertised = host === '0.0.0.0' ? '127.0.0.1' : host === '::' ? '::1' : host
83
+ return `http://${formatUrlHost(advertised)}:${config.server.port}`
84
+ }
85
+ try {
86
+ const origin = webServer.getOrigin()
87
+ if (typeof origin === 'string' && origin !== '') return trimTrailingSlash(origin)
88
+ } catch {
89
+ /* not listening yet; fall through */
90
+ }
91
+ const port = Number(webServer.port)
92
+ if (Number.isInteger(port) && port > 0) {
93
+ logger.warn(`[a2a] webServer.getOrigin() unavailable; advertising http://127.0.0.1:${port}`)
94
+ return `http://127.0.0.1:${port}`
95
+ }
96
+ logger.warn('[a2a] cannot determine a public origin; set server.publicUrl in the plugin config')
97
+ return 'http://127.0.0.1'
98
+ }
99
+
100
+ /** @returns {boolean} whether the request targets the REST binding. */
101
+ function isRestBinding(req, mountPath) {
102
+ const path = String(req.originalUrl ?? req.url ?? '')
103
+ return path.startsWith(`${mountPath}/rest`)
104
+ }
105
+
106
+ /** Build the CORS middleware for the configured policy. */
107
+ function createCors(config) {
108
+ const cors = config.server.cors
109
+ return (req, res, next) => {
110
+ if (!cors.enabled) {
111
+ next()
112
+ return
113
+ }
114
+ const origin = req.headers.origin
115
+ const wildcard = cors.origins.includes('*')
116
+ const allowed = wildcard ? '*' : origin !== undefined && cors.origins.includes(origin) ? origin : null
117
+ if (allowed !== null) {
118
+ res.setHeader('Access-Control-Allow-Origin', allowed)
119
+ if (!wildcard) res.setHeader('Vary', 'Origin')
120
+ if (cors.allowCredentials) res.setHeader('Access-Control-Allow-Credentials', 'true')
121
+ res.setHeader('Access-Control-Expose-Headers', 'A2A-Extensions, A2A-Version, ETag')
122
+ }
123
+ if (req.method === 'OPTIONS') {
124
+ if (allowed !== null) {
125
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
126
+ res.setHeader(
127
+ 'Access-Control-Allow-Headers',
128
+ `authorization, content-type, a2a-version, a2a-extensions, ${config.auth.headerName}`,
129
+ )
130
+ res.setHeader('Access-Control-Max-Age', String(cors.maxAgeSeconds))
131
+ }
132
+ res.statusCode = 204
133
+ res.end()
134
+ return
135
+ }
136
+ next()
137
+ }
138
+ }
139
+
140
+ /** Write one error body in the shape of the binding the caller used. */
141
+ function writeError(req, res, { status, code, message, restBinding, rpcCode = -32603 }) {
142
+ if (res.headersSent) {
143
+ res.end()
144
+ return
145
+ }
146
+ res.statusCode = status
147
+ res.setHeader('Content-Type', restBinding ? A2A_CONTENT_TYPE : JSON_CONTENT_TYPE)
148
+ res.setHeader('Cache-Control', 'no-store')
149
+ const details = [
150
+ {
151
+ '@type': 'type.googleapis.com/google.rpc.ErrorInfo',
152
+ reason: code,
153
+ domain: 'a2a-protocol.org',
154
+ metadata: { path: String(req.originalUrl ?? req.url ?? '') },
155
+ },
156
+ ]
157
+ if (restBinding) {
158
+ res.end(JSON.stringify({ error: { code: status, status: code, message, details } }))
159
+ } else {
160
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: rpcCode, message, data: details } }))
161
+ }
162
+ }
163
+
164
+ /**
165
+ * One mounted A2A deployment. Mounting is synchronous so route registration
166
+ * can participate in Cordis effects; only request handling touches async
167
+ * agent work. The standalone listener is created here and reports its bind
168
+ * result through {@link A2AService.serverStatus}.
169
+ */
170
+ export class A2AService {
171
+ #ctx
172
+ #config
173
+ #logger
174
+ #mounted = undefined
175
+ #unmount = undefined
176
+ #standalone = undefined
177
+ #disposed = false
178
+
179
+ /**
180
+ * @param {object} ctx owning Cordis context
181
+ * @param {Readonly<object>} config normalized configuration
182
+ */
183
+ constructor(ctx, config) {
184
+ this.#ctx = ctx
185
+ this.#config = config
186
+ this.#logger = createLogger(ctx)
187
+ }
188
+
189
+ /** @returns {object} the structured logger used by every component */
190
+ get logger() {
191
+ return this.#logger
192
+ }
193
+
194
+ /**
195
+ * Describe the active listening mode for health checks and the settings
196
+ * panel. In shared mode the DSH web server owns the socket, so `listening`
197
+ * only means the plugin is mounted.
198
+ *
199
+ * @returns {{ mode: 'shared'|'standalone', host: string|null, port: number|null, listening: boolean, error: string|null, publicUrl: string|null }}
200
+ */
201
+ serverStatus() {
202
+ const config = this.#config
203
+ const mounted = this.#mounted
204
+ if (config.server.port === null) {
205
+ return {
206
+ mode: 'shared',
207
+ host: null,
208
+ port: null,
209
+ listening: mounted !== undefined,
210
+ error: null,
211
+ publicUrl: mounted?.publicBaseUrl ?? null,
212
+ }
213
+ }
214
+ const state = this.#standalone
215
+ return {
216
+ mode: 'standalone',
217
+ host: config.server.host,
218
+ port: config.server.port,
219
+ listening: state?.listening === true,
220
+ error: state?.error ?? null,
221
+ publicUrl: mounted?.publicBaseUrl ?? null,
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Mount the A2A deployment.
227
+ *
228
+ * @param {object} webServer the `webServer` service
229
+ * @returns {() => Promise<void>} disposer that unregisters routes, closes the
230
+ * standalone listener and drains work
231
+ */
232
+ mount(webServer) {
233
+ if (this.#mounted !== undefined) throw new Error('a2a: already mounted')
234
+ if (this.#disposed) throw new Error('a2a: service is disposed')
235
+ const config = this.#config
236
+ const logger = this.#logger
237
+ const mountPath = config.server.mountPath
238
+ const standalone = config.server.port !== null
239
+
240
+ const credentials = resolveCredentials(config)
241
+ const publicBaseUrl = resolvePublicBase(config, webServer, logger)
242
+ const authEnabled = isAuthEnabled(config, credentials)
243
+ let extendedCard = config.server.extendedCard
244
+ if (extendedCard && !authEnabled) {
245
+ logger.warn(
246
+ '[a2a] server.extendedCard requires authentication; the extended Agent Card stays disabled because auth.mode is "none"',
247
+ )
248
+ extendedCard = false
249
+ }
250
+
251
+ const effective = {
252
+ ...config,
253
+ server: { ...config.server, extendedCard, pushNotifications: config.server.pushNotifications },
254
+ }
255
+
256
+ const cardOptions = { publicBaseUrl, credentials }
257
+ const baseCard = buildAgentCard(effective, cardOptions)
258
+ const extendedAgentCard = buildExtendedCard(effective, cardOptions)
259
+ const signer = createCardSigner(config)
260
+
261
+ const runtime = new AgentRuntime({ ctx: this.#ctx, config, logger })
262
+ const executor = new HarnessAgentExecutor({ runtime, config, logger })
263
+ const taskStore = new A2ATaskStore({ ...config.tasks, logger })
264
+
265
+ const pushStore = config.server.pushNotifications
266
+ ? new GuardedPushNotificationStore(new InMemoryPushNotificationStore(), {
267
+ allowPrivateTargets: config.push.allowPrivateTargets,
268
+ maxConfigsPerTask: 10,
269
+ logger,
270
+ })
271
+ : undefined
272
+ const pushSender =
273
+ pushStore === undefined
274
+ ? undefined
275
+ : new DefaultPushNotificationSender(pushStore, { timeout: config.push.timeoutMs })
276
+
277
+ const requestHandler = new DefaultRequestHandler(
278
+ baseCard,
279
+ taskStore,
280
+ executor,
281
+ undefined,
282
+ pushStore,
283
+ pushSender,
284
+ extendedCard ? async () => extendedAgentCard : undefined,
285
+ signer ?? undefined,
286
+ { validateInputModes: true },
287
+ )
288
+
289
+ const authenticator = createAuthenticator({
290
+ config,
291
+ credentials,
292
+ logger,
293
+ isRestBinding: (req) => isRestBinding(req, mountPath),
294
+ })
295
+
296
+ const cardRouter = agentCardHandler({
297
+ agentCardProvider: requestHandler,
298
+ cache: { maxAge: config.server.cardCacheSeconds },
299
+ })
300
+ const jsonRpcRouter = jsonRpcHandler({ requestHandler, userBuilder: authenticator.userBuilder })
301
+ const restRouter = restHandler({ requestHandler, userBuilder: authenticator.userBuilder })
302
+ const healthSnapshot = () =>
303
+ this.#health({ runtime, executor, taskStore, authenticator, publicBaseUrl, extendedCard })
304
+ const panelRouter = createPanelRouter({
305
+ ctx: this.#ctx,
306
+ config,
307
+ mountPath,
308
+ logger,
309
+ status: () => this.serverStatus(),
310
+ // The panel is same-origin with the GUI but possibly cross-origin to the
311
+ // A2A port; return the health snapshot here so the panel does not depend
312
+ // on CORS being enabled on the standalone listener.
313
+ health: healthSnapshot,
314
+ })
315
+
316
+ const applyCommon = (app) => {
317
+ app.disable('x-powered-by')
318
+ app.set('trust proxy', config.server.trustProxy)
319
+ app.set('query parser', 'simple')
320
+ app.use(createCors(config))
321
+ app.use((req, res, next) => {
322
+ res.setHeader('X-Content-Type-Options', 'nosniff')
323
+ next()
324
+ })
325
+ if (config.server.logRequests) {
326
+ app.use((req, res, next) => {
327
+ const started = Date.now()
328
+ res.on('finish', () => {
329
+ logger.info(`${req.method} ${req.originalUrl} → ${res.statusCode} (${Date.now() - started}ms)`)
330
+ })
331
+ next()
332
+ })
333
+ }
334
+ // Parse bodies once with the configured limit. The SDK transports skip
335
+ // their own `express.json()` because body-parser marks the request parsed.
336
+ app.use(
337
+ express.json({
338
+ limit: config.limits.maxRequestBytes,
339
+ strict: false,
340
+ type: [JSON_CONTENT_TYPE, A2A_CONTENT_TYPE],
341
+ }),
342
+ )
343
+ }
344
+
345
+ const a2aErrorHandler = (error, req, res, next) => {
346
+ if (res.headersSent) {
347
+ res.end()
348
+ return
349
+ }
350
+ const status =
351
+ typeof error?.status === 'number' && error.status >= 400 && error.status <= 599 ? error.status : 500
352
+ const restBinding = isRestBinding(req, mountPath)
353
+ if (status >= 500) logger.warn(`[a2a] request failed: ${errorMessage(error)}`)
354
+ const isParseError = error?.type === 'entity.parse.failed'
355
+ const isTooLarge = error?.type === 'entity.too.large'
356
+ writeError(req, res, {
357
+ status: isTooLarge ? 413 : isParseError ? 400 : status,
358
+ code: isTooLarge ? 'RESOURCE_EXHAUSTED' : isParseError ? 'INVALID_ARGUMENT' : status >= 500 ? 'INTERNAL' : 'INVALID_ARGUMENT',
359
+ message: isTooLarge
360
+ ? `Request body exceeds limits.maxRequestBytes (${config.limits.maxRequestBytes} bytes)`
361
+ : isParseError
362
+ ? 'Invalid JSON payload'
363
+ : errorMessage(error),
364
+ restBinding,
365
+ rpcCode: isParseError ? -32700 : isTooLarge ? -32600 : -32603,
366
+ })
367
+ void next
368
+ }
369
+
370
+ const panelErrorHandler = (error, req, res, next) => {
371
+ if (res.headersSent) {
372
+ res.end()
373
+ return
374
+ }
375
+ const status =
376
+ typeof error?.status === 'number' && error.status >= 400 && error.status <= 599 ? error.status : 500
377
+ if (status >= 500) logger.warn(`[a2a] panel request failed: ${errorMessage(error)}`)
378
+ res.statusCode = status
379
+ res.setHeader('Content-Type', JSON_CONTENT_TYPE)
380
+ res.setHeader('Cache-Control', 'no-store')
381
+ res.end(
382
+ JSON.stringify({
383
+ error: status >= 500 ? 'INTERNAL' : 'INVALID_ARGUMENT',
384
+ message: error?.type === 'entity.parse.failed' ? 'Invalid JSON payload' : errorMessage(error),
385
+ }),
386
+ )
387
+ void next
388
+ }
389
+
390
+ const a2aApp = express()
391
+ applyCommon(a2aApp)
392
+ // The Agent Card is discovery metadata and stays public.
393
+ a2aApp.use(config.server.cardPath, cardRouter)
394
+ if (!standalone) {
395
+ // In shared mode the panel is part of the same app and must precede the
396
+ // A2A auth middleware.
397
+ a2aApp.use(`${mountPath}/panel`, panelRouter)
398
+ }
399
+ // Everything below the mount path is the A2A endpoint.
400
+ a2aApp.use(mountPath, authenticator.middleware)
401
+ a2aApp.use(`${mountPath}/rest`, restRouter)
402
+ a2aApp.use(mountPath, jsonRpcRouter)
403
+ a2aApp.get(mountPath, (req, res) => {
404
+ res.setHeader('Allow', 'POST')
405
+ writeError(req, res, {
406
+ status: 405,
407
+ code: 'METHOD_NOT_ALLOWED',
408
+ message: `The A2A JSON-RPC endpoint only accepts POST; REST clients use ${mountPath}/rest.`,
409
+ restBinding: false,
410
+ })
411
+ })
412
+ if (config.server.health) {
413
+ a2aApp.get(`${mountPath}/health`, (req, res) => {
414
+ res.setHeader('Cache-Control', 'no-store')
415
+ res.json(healthSnapshot())
416
+ })
417
+ }
418
+ a2aApp.use((req, res) => {
419
+ writeError(req, res, {
420
+ status: 404,
421
+ code: 'NOT_FOUND',
422
+ message: `No A2A route matches ${req.method} ${req.originalUrl}`,
423
+ restBinding: isRestBinding(req, mountPath),
424
+ })
425
+ })
426
+ a2aApp.use(a2aErrorHandler)
427
+
428
+ const wrap = (app) => (req, res) => {
429
+ try {
430
+ app(req, res)
431
+ } catch (error) {
432
+ logger.warn(`[a2a] transport failure: ${errorMessage(error)}`)
433
+ if (!res.headersSent) {
434
+ res.statusCode = 500
435
+ res.setHeader('Content-Type', JSON_CONTENT_TYPE)
436
+ res.end(JSON.stringify({ error: { code: 500, status: 'INTERNAL', message: 'internal error' } }))
437
+ } else if (!res.writableEnded) {
438
+ res.end()
439
+ }
440
+ }
441
+ }
442
+ const callA2A = wrap(a2aApp)
443
+
444
+ let callPanel
445
+ if (standalone) {
446
+ // The settings panel stays on the Harness Web origin so it can reuse the
447
+ // GUI's authenticated browser session. It is not exposed on the A2A port.
448
+ const panelApp = express()
449
+ panelApp.disable('x-powered-by')
450
+ panelApp.set('trust proxy', config.server.trustProxy)
451
+ panelApp.use(`${mountPath}/panel`, panelRouter)
452
+ panelApp.use((req, res) => {
453
+ res.statusCode = 404
454
+ res.setHeader('Content-Type', JSON_CONTENT_TYPE)
455
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `No settings panel route matches ${req.url}` }))
456
+ })
457
+ panelApp.use(panelErrorHandler)
458
+ callPanel = wrap(panelApp)
459
+ }
460
+
461
+ const routeDisposers = []
462
+ let standaloneState
463
+ const registerRoute = (route, handler, label, { optional = false } = {}) => {
464
+ try {
465
+ routeDisposers.push(webServer.register({ ...route, handler }))
466
+ } catch (error) {
467
+ if (optional) {
468
+ logger.warn(`[a2a] cannot register ${label} at ${route.path}: ${errorMessage(error)}`)
469
+ return
470
+ }
471
+ throw new Error(`a2a: cannot register ${label} at ${route.path}: ${errorMessage(error)}`)
472
+ }
473
+ }
474
+
475
+ const cleanupAfterFailure = () => {
476
+ for (const dispose of routeDisposers.splice(0).reverse()) {
477
+ try {
478
+ dispose()
479
+ } catch {
480
+ /* ignore during rollback */
481
+ }
482
+ }
483
+ if (standaloneState !== undefined) {
484
+ standaloneState.server.close()
485
+ standaloneState.server.closeAllConnections?.()
486
+ this.#standalone = undefined
487
+ }
488
+ void executor.dispose().catch((error) => logger.warn(`[a2a] rollback dispose failed: ${errorMessage(error)}`))
489
+ void runtime.shutdown().catch((error) => logger.warn(`[a2a] rollback shutdown failed: ${errorMessage(error)}`))
490
+ }
491
+
492
+ try {
493
+ // Discovery stays reachable on the Harness Web origin in both modes: in
494
+ // shared mode it is the endpoint, and in standalone mode it bootstraps
495
+ // the settings panel (the card itself points at the dedicated port).
496
+ registerRoute({ kind: 'exact', path: config.server.cardPath }, callA2A, 'agent card')
497
+ if (standalone) {
498
+ registerRoute(
499
+ { kind: 'prefix', path: `${mountPath}/panel` },
500
+ callPanel,
501
+ 'settings panel',
502
+ )
503
+ const server = http.createServer(callA2A)
504
+ standaloneState = {
505
+ server,
506
+ host: config.server.host,
507
+ port: config.server.port,
508
+ listening: false,
509
+ error: null,
510
+ }
511
+ this.#standalone = standaloneState
512
+ server.on('error', (error) => {
513
+ standaloneState.error = errorMessage(error)
514
+ standaloneState.listening = false
515
+ logger.error(
516
+ `[a2a] standalone listener ${config.server.host}:${config.server.port} failed: ${errorMessage(error)}`,
517
+ )
518
+ })
519
+ server.on('listening', () => {
520
+ standaloneState.listening = true
521
+ standaloneState.error = null
522
+ logger.info(
523
+ `[a2a] standalone listener ready on ${formatUrlHost(config.server.host)}:${config.server.port}`,
524
+ )
525
+ })
526
+ try {
527
+ server.listen(config.server.port, config.server.host)
528
+ } catch (error) {
529
+ standaloneState.error = errorMessage(error)
530
+ logger.error(`[a2a] standalone listener cannot bind: ${errorMessage(error)}`)
531
+ }
532
+ } else {
533
+ registerRoute({ kind: 'prefix', path: mountPath }, callA2A, 'A2A endpoint')
534
+ }
535
+ } catch (error) {
536
+ cleanupAfterFailure()
537
+ throw error
538
+ }
539
+
540
+ const sweepTimer = setInterval(() => {
541
+ try {
542
+ taskStore.sweep()
543
+ } catch (error) {
544
+ logger.warn(`[a2a] task sweep failed: ${errorMessage(error)}`)
545
+ }
546
+ if (config.files.enabled) {
547
+ runtime
548
+ .resolveCwd()
549
+ .then((cwd) =>
550
+ sweepAttachmentDirectories({
551
+ cwd,
552
+ directory: config.files.directory,
553
+ retentionMs: config.files.retentionMs,
554
+ logger,
555
+ }),
556
+ )
557
+ .then(({ removed }) => {
558
+ if (removed > 0) logger.info(`removed ${removed} stale attachment director${removed === 1 ? 'y' : 'ies'}`)
559
+ })
560
+ .catch((error) => logger.warn(`[a2a] attachment sweep failed: ${errorMessage(error)}`))
561
+ }
562
+ }, config.tasks.sweepIntervalMs)
563
+ sweepTimer.unref?.()
564
+ runtime.start()
565
+
566
+ const startedAt = Date.now()
567
+ this.#mounted = { startedAt, publicBaseUrl, standalone }
568
+ if (standalone) {
569
+ logger.info(
570
+ `A2A v${A2A_PROTOCOL_VERSION} standalone server: ${publicBaseUrl}${mountPath} (JSON-RPC), ` +
571
+ `${publicBaseUrl}${mountPath}/rest (HTTP+JSON), card ${publicBaseUrl}${config.server.cardPath}`,
572
+ )
573
+ logger.info(
574
+ `settings panel and card mirror on the Harness Web origin: ${webServer.getOrigin()}${mountPath}/panel/config`,
575
+ )
576
+ } else {
577
+ logger.info(
578
+ `A2A v${A2A_PROTOCOL_VERSION} server ready (shared port): ${publicBaseUrl}${mountPath} (JSON-RPC), ` +
579
+ `${publicBaseUrl}${mountPath}/rest (HTTP+JSON), card ${publicBaseUrl}${config.server.cardPath}`,
580
+ )
581
+ }
582
+ logger.info(
583
+ `auth=${authenticator.describe().mode} streaming=${config.server.streaming} ` +
584
+ `push=${config.server.pushNotifications} extendedCard=${extendedCard}`,
585
+ )
586
+
587
+ let released = false
588
+ const unmount = async () => {
589
+ if (released) return
590
+ released = true
591
+ clearInterval(sweepTimer)
592
+ for (const dispose of routeDisposers.splice(0).reverse()) {
593
+ try {
594
+ dispose()
595
+ } catch (error) {
596
+ logger.warn(`[a2a] route disposal failed: ${errorMessage(error)}`)
597
+ }
598
+ }
599
+ const state = this.#standalone
600
+ this.#standalone = undefined
601
+ if (state !== undefined) {
602
+ await new Promise((resolve) => {
603
+ state.server.close(() => resolve())
604
+ state.server.closeAllConnections?.()
605
+ })
606
+ state.listening = false
607
+ }
608
+ await executor.dispose()
609
+ await runtime.shutdown()
610
+ this.#mounted = undefined
611
+ this.#unmount = undefined
612
+ logger.info('[a2a] server stopped')
613
+ }
614
+ this.#unmount = unmount
615
+ return unmount
616
+ }
617
+
618
+ #health({ runtime, executor, taskStore, authenticator, publicBaseUrl, extendedCard }) {
619
+ const config = this.#config
620
+ const mounted = this.#mounted
621
+ return {
622
+ status: 'ok',
623
+ protocolVersion: A2A_PROTOCOL_VERSION,
624
+ name: config.card.name,
625
+ version: config.card.version,
626
+ publicUrl: publicBaseUrl,
627
+ uptimeSeconds: mounted === undefined ? 0 : Math.round((Date.now() - mounted.startedAt) / 1000),
628
+ server: this.serverStatus(),
629
+ interfaces: [
630
+ { protocolBinding: 'JSONRPC', url: `${publicBaseUrl}${config.server.mountPath}` },
631
+ { protocolBinding: 'HTTP+JSON', url: `${publicBaseUrl}${config.server.mountPath}/rest` },
632
+ ],
633
+ capabilities: {
634
+ streaming: config.server.streaming,
635
+ pushNotifications: config.server.pushNotifications,
636
+ extendedAgentCard: extendedCard,
637
+ },
638
+ auth: authenticator.describe(),
639
+ signing: config.signing.enabled
640
+ ? { enabled: true, algorithm: config.signing.algorithm, keyId: config.signing.keyId }
641
+ : { enabled: false },
642
+ tasks: taskStore.stats(),
643
+ agents: runtime.stats(),
644
+ concurrency: executor.concurrency(),
645
+ }
646
+ }
647
+
648
+ /** Release service-wide resources. Safe to call multiple times. */
649
+ async dispose() {
650
+ if (this.#disposed) return
651
+ this.#disposed = true
652
+ const unmount = this.#unmount
653
+ this.#unmount = undefined
654
+ if (unmount !== undefined) await unmount()
655
+ }
656
+ }