@hanphone/dsh-a2a 0.2.0 → 0.3.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 +101 -106
- package/README.zh.md +50 -74
- package/cordis.patch.yml +4 -7
- package/lib/client.js +217 -73
- package/lib/index.js +1258 -867
- package/lib/types/api.d.ts +40 -23
- package/lib/types/api.d.ts.map +1 -1
- package/lib/types/api.js +58 -23
- package/lib/types/api.js.map +1 -1
- package/lib/types/client/index.d.ts +50 -19
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/commands.d.ts +4 -2
- package/lib/types/commands.d.ts.map +1 -1
- package/lib/types/commands.js +59 -46
- package/lib/types/commands.js.map +1 -1
- package/lib/types/events.d.ts +32 -9
- package/lib/types/events.d.ts.map +1 -1
- package/lib/types/index.d.ts +21 -32
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +173 -272
- package/lib/types/index.js.map +1 -1
- package/lib/types/outbound/calls.d.ts.map +1 -1
- package/lib/types/outbound/calls.js +8 -6
- package/lib/types/outbound/calls.js.map +1 -1
- package/lib/types/protocol.d.ts +174 -109
- package/lib/types/protocol.d.ts.map +1 -1
- package/lib/types/protocol.js +58 -34
- package/lib/types/protocol.js.map +1 -1
- package/lib/types/server/a2a-server.d.ts +4 -0
- package/lib/types/server/a2a-server.d.ts.map +1 -1
- package/lib/types/server/a2a-server.js +6 -2
- package/lib/types/server/a2a-server.js.map +1 -1
- package/lib/types/server/card.d.ts +6 -30
- package/lib/types/server/card.d.ts.map +1 -1
- package/lib/types/server/card.js +13 -49
- package/lib/types/server/card.js.map +1 -1
- package/lib/types/server/exec/agent-runtime.d.ts +3 -0
- package/lib/types/server/exec/agent-runtime.d.ts.map +1 -1
- package/lib/types/server/exec/agent-runtime.js +2 -1
- package/lib/types/server/exec/agent-runtime.js.map +1 -1
- package/lib/types/server/routes.d.ts +5 -1
- package/lib/types/server/routes.d.ts.map +1 -1
- package/lib/types/server/routes.js +9 -5
- package/lib/types/server/routes.js.map +1 -1
- package/lib/types/server/store.d.ts +35 -1
- package/lib/types/server/store.d.ts.map +1 -1
- package/lib/types/server/store.js +10 -0
- package/lib/types/server/store.js.map +1 -1
- package/lib/types/servers/inbound-manager.d.ts +131 -0
- package/lib/types/servers/inbound-manager.d.ts.map +1 -0
- package/lib/types/servers/inbound-manager.js +312 -0
- package/lib/types/servers/inbound-manager.js.map +1 -0
- package/lib/types/servers/outbound-manager.d.ts +129 -0
- package/lib/types/servers/outbound-manager.d.ts.map +1 -0
- package/lib/types/servers/outbound-manager.js +238 -0
- package/lib/types/servers/outbound-manager.js.map +1 -0
- package/lib/types/service.d.ts +106 -40
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +36 -27
- package/lib/types/service.js.map +1 -1
- package/package.json +2 -2
- package/src/api.ts +67 -53
- package/src/client/index.ts +309 -148
- package/src/commands.ts +59 -43
- package/src/events.ts +14 -7
- package/src/index.ts +199 -310
- package/src/outbound/calls.ts +8 -6
- package/src/protocol.ts +204 -95
- package/src/server/a2a-server.ts +9 -2
- package/src/server/card.ts +13 -62
- package/src/server/exec/agent-runtime.ts +5 -2
- package/src/server/routes.ts +6 -4
- package/src/server/store.ts +45 -4
- package/src/servers/inbound-manager.ts +385 -0
- package/src/servers/outbound-manager.ts +289 -0
- package/src/service.ts +125 -40
- package/lib/tsconfig.client.tsbuildinfo +0 -1
- package/lib/tsconfig.tsbuildinfo +0 -1
package/src/server/store.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
type DomainFacility,
|
|
16
16
|
type KvTable,
|
|
17
17
|
} from '@deepseek-ai/dsh-storage-domain'
|
|
18
|
-
import { TaskState, type Part } from '../protocol.ts'
|
|
18
|
+
import { TaskState, type AgentSkill, type Part } from '../protocol.ts'
|
|
19
19
|
|
|
20
20
|
/** One durable inbound/outbound task record. */
|
|
21
21
|
export interface TaskRecord {
|
|
@@ -32,6 +32,8 @@ export interface TaskRecord {
|
|
|
32
32
|
readonly executor: 'session' | 'subagent'
|
|
33
33
|
readonly summary: string | null
|
|
34
34
|
readonly error?: { readonly code: string; readonly message: string }
|
|
35
|
+
/** The inbound server instance this task arrived through. */
|
|
36
|
+
readonly serverId?: string
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/** One durable context→session binding (inbound conversation ↔ DSH session). */
|
|
@@ -50,6 +52,33 @@ export interface OutboundAgentRecord {
|
|
|
50
52
|
readonly lastCardAt: string | null
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
/** One durable inbound server instance. */
|
|
56
|
+
export interface InboundServerRecord {
|
|
57
|
+
readonly id: string
|
|
58
|
+
readonly name: string
|
|
59
|
+
readonly description: string
|
|
60
|
+
readonly version: string
|
|
61
|
+
readonly endpointPath: string
|
|
62
|
+
/** Agent preset id composing inbound sessions; absent = deployment default. */
|
|
63
|
+
readonly preset?: string
|
|
64
|
+
readonly authTokenEnv?: string
|
|
65
|
+
/** Advertised skills (creator-entered declarations, default = preset name). */
|
|
66
|
+
readonly skills: readonly AgentSkill[]
|
|
67
|
+
readonly enabled: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One durable outbound server connection instance. */
|
|
71
|
+
export interface OutboundServerRecord {
|
|
72
|
+
readonly id: string
|
|
73
|
+
readonly name: string
|
|
74
|
+
readonly agentCardUrl: string
|
|
75
|
+
readonly bearerTokenEnv?: string
|
|
76
|
+
/** Preset composing this DSH's local session when talking to the remote. */
|
|
77
|
+
readonly preset?: string
|
|
78
|
+
readonly enabled: boolean
|
|
79
|
+
readonly timeoutMs: number
|
|
80
|
+
}
|
|
81
|
+
|
|
53
82
|
/** JSON-encoded record schema shared by every `a2a` table. */
|
|
54
83
|
const json = z.string()
|
|
55
84
|
|
|
@@ -62,6 +91,8 @@ export const a2aDomainSpec = defineDomain({
|
|
|
62
91
|
contexts: domainTable<string, string>(json),
|
|
63
92
|
agents: domainTable<string, string>(json),
|
|
64
93
|
identity: domainTable<string, string>(json),
|
|
94
|
+
inbound_servers: domainTable<string, string>(json),
|
|
95
|
+
outbound_servers: domainTable<string, string>(json),
|
|
65
96
|
},
|
|
66
97
|
})
|
|
67
98
|
|
|
@@ -112,6 +143,14 @@ export class A2aDomain {
|
|
|
112
143
|
return this.handle.table('identity')
|
|
113
144
|
}
|
|
114
145
|
|
|
146
|
+
get inbound_servers(): KvTable<string, string> {
|
|
147
|
+
return this.handle.table('inbound_servers')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
get outbound_servers(): KvTable<string, string> {
|
|
151
|
+
return this.handle.table('outbound_servers')
|
|
152
|
+
}
|
|
153
|
+
|
|
115
154
|
async close(): Promise<void> {
|
|
116
155
|
await this.handle.close()
|
|
117
156
|
}
|
|
@@ -119,7 +158,7 @@ export class A2aDomain {
|
|
|
119
158
|
|
|
120
159
|
/** Task-store contract shared by the domain-backed and memory stores. */
|
|
121
160
|
export interface TaskStore {
|
|
122
|
-
create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null }): TaskRecord
|
|
161
|
+
create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null; serverId?: string }): TaskRecord
|
|
123
162
|
get(taskId: string): TaskRecord | undefined
|
|
124
163
|
list(): TaskRecord[]
|
|
125
164
|
setState(taskId: string, state: TaskState, message?: { code: string; text: string }): TaskRecord
|
|
@@ -146,7 +185,7 @@ export class MemoryTaskStore implements TaskStore {
|
|
|
146
185
|
private tasks = new Map<string, TaskRecord>()
|
|
147
186
|
private contexts = new Map<string, string>()
|
|
148
187
|
|
|
149
|
-
create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null }): TaskRecord {
|
|
188
|
+
create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null; serverId?: string }): TaskRecord {
|
|
150
189
|
const record: TaskRecord = {
|
|
151
190
|
taskId: `a2a-${crypto.randomUUID()}`,
|
|
152
191
|
contextId: input.contextId,
|
|
@@ -160,6 +199,7 @@ export class MemoryTaskStore implements TaskStore {
|
|
|
160
199
|
artifacts: [],
|
|
161
200
|
executor: 'session',
|
|
162
201
|
summary: null,
|
|
202
|
+
...(input.serverId !== undefined ? { serverId: input.serverId } : {}),
|
|
163
203
|
}
|
|
164
204
|
this.tasks.set(record.taskId, record)
|
|
165
205
|
return record
|
|
@@ -244,7 +284,7 @@ export class DomainTaskStore implements TaskStore {
|
|
|
244
284
|
}
|
|
245
285
|
}
|
|
246
286
|
|
|
247
|
-
create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null }): TaskRecord {
|
|
287
|
+
create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null; serverId?: string }): TaskRecord {
|
|
248
288
|
const record: TaskRecord = {
|
|
249
289
|
taskId: `a2a-${crypto.randomUUID()}`,
|
|
250
290
|
contextId: input.contextId,
|
|
@@ -258,6 +298,7 @@ export class DomainTaskStore implements TaskStore {
|
|
|
258
298
|
artifacts: [],
|
|
259
299
|
executor: 'session',
|
|
260
300
|
summary: null,
|
|
301
|
+
...(input.serverId !== undefined ? { serverId: input.serverId } : {}),
|
|
261
302
|
}
|
|
262
303
|
this.records.set(record.taskId, record)
|
|
263
304
|
// Persist eagerly; a failed write must fail the task, not silently lose it.
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound server manager: owns every inbound A2A server instance. Each
|
|
3
|
+
* instance binds one agent preset (its session pool is composed from that
|
|
4
|
+
* preset), advertises creator-declared skills, and serves its own endpoint and
|
|
5
|
+
* AgentCard route. Instances are persisted in the `inbound_servers` domain
|
|
6
|
+
* table and assembled on boot; the manager owns route registration and the
|
|
7
|
+
* full lifecycle (add/enable/disable/update/remove) driven from the GUI and
|
|
8
|
+
* facade, and disposes every instance's environment on teardown.
|
|
9
|
+
*
|
|
10
|
+
* Skill declaration: the stored `skills` list is exactly what the creator
|
|
11
|
+
* declared. When a creator leaves it empty at creation time, the default skill
|
|
12
|
+
* derives from the bound preset's display name (else the built-in `chat`
|
|
13
|
+
* skill), per the v1.0 design — the v0.2 tool white-list derivation is gone.
|
|
14
|
+
* @module dsh-a2a/servers/inbound-manager
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
18
|
+
import { buildCard } from '../server/card.ts'
|
|
19
|
+
import { A2AServer } from '../server/a2a-server.ts'
|
|
20
|
+
import { A2aRoutes } from '../server/routes.ts'
|
|
21
|
+
import { ContextSessionPool, type AgentPresetsLike, type AgentRegistryLike } from '../server/exec/agent-runtime.ts'
|
|
22
|
+
import { createSessionExecutor } from '../server/exec/session.ts'
|
|
23
|
+
import { createSubagentExecutor, type SubagentsLike } from '../server/exec/subagent.ts'
|
|
24
|
+
import { ExecutorSet } from '../server/executor.ts'
|
|
25
|
+
import { LiveInboundRegistry } from '../server/inbound-registry.ts'
|
|
26
|
+
import type { TaskStore, InboundServerRecord } from '../server/store.ts'
|
|
27
|
+
import type { AgentSkill } from '../protocol.ts'
|
|
28
|
+
import type { GateInput, GateResult } from '../server/a2a-server.ts'
|
|
29
|
+
import type { InboundTaskDecision } from '../events.ts'
|
|
30
|
+
|
|
31
|
+
/** Web-server slice the manager registers instance routes on. */
|
|
32
|
+
export interface WebServerLike {
|
|
33
|
+
register(route: {
|
|
34
|
+
readonly kind: 'exact' | 'prefix'
|
|
35
|
+
readonly path: string
|
|
36
|
+
handler(...args: unknown[]): unknown
|
|
37
|
+
}): () => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Host service slices one manager needs. */
|
|
41
|
+
export interface InboundManagerHost {
|
|
42
|
+
readonly ctx: Context
|
|
43
|
+
readonly webServer: WebServerLike
|
|
44
|
+
readonly tasks: TaskStore
|
|
45
|
+
readonly logger: { info(message: string): void; warn(message: string): void; error(message: string): void }
|
|
46
|
+
readonly agents?: AgentRegistryLike
|
|
47
|
+
readonly agentPresets?: AgentPresetsLike
|
|
48
|
+
readonly subagents?: SubagentsLike
|
|
49
|
+
readonly sessionCwd: string
|
|
50
|
+
readonly defaultBaseUrl: string
|
|
51
|
+
readonly subagentProvider: string
|
|
52
|
+
/** Resolve the deployment's default model options (may be undefined). */
|
|
53
|
+
readonly resolveDefaultModel?: () => { readonly provider?: string; readonly model?: string } | undefined
|
|
54
|
+
/** Allocate a fresh stable instance id. */
|
|
55
|
+
readonly newId: () => string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One live inbound instance's runtime handle. */
|
|
59
|
+
export interface LiveInboundServer {
|
|
60
|
+
readonly id: string
|
|
61
|
+
readonly record: InboundServerRecord
|
|
62
|
+
readonly card: ReturnType<typeof buildCard>
|
|
63
|
+
readonly server: A2AServer
|
|
64
|
+
readonly routes: A2aRoutes
|
|
65
|
+
readonly inbound: LiveInboundRegistry
|
|
66
|
+
readonly executors: ExecutorSet
|
|
67
|
+
readonly endpointPath: string
|
|
68
|
+
readonly cardPath: string
|
|
69
|
+
dispose(): void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Creator input for a new (or updated) inbound instance. */
|
|
73
|
+
export interface InboundCreateInput {
|
|
74
|
+
readonly name: string
|
|
75
|
+
readonly description: string
|
|
76
|
+
readonly version: string
|
|
77
|
+
readonly endpointPath?: string
|
|
78
|
+
readonly preset?: string
|
|
79
|
+
readonly authTokenEnv?: string
|
|
80
|
+
/** Declared skills; empty defaults to the preset name (or `chat`). */
|
|
81
|
+
readonly skills?: readonly AgentSkill[]
|
|
82
|
+
readonly enabled?: boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface OpResult {
|
|
86
|
+
readonly ok: boolean
|
|
87
|
+
readonly message: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function refuseExecutor(reason: string) {
|
|
91
|
+
return {
|
|
92
|
+
name: 'refuse',
|
|
93
|
+
async execute(): Promise<{ parts: { text: string }[] }> {
|
|
94
|
+
throw new Error(`a2a: ${reason}; refusing inbound task`)
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Default skkill when a creator declares none: the preset display name. */
|
|
100
|
+
export function defaultSkillFor(
|
|
101
|
+
skills: readonly AgentSkill[] | undefined,
|
|
102
|
+
presetName: string | undefined,
|
|
103
|
+
): readonly AgentSkill[] {
|
|
104
|
+
if (skills !== undefined && skills.length > 0) return skills
|
|
105
|
+
if (presetName !== undefined && presetName.length > 0) {
|
|
106
|
+
return [{ id: 'chat', name: presetName, description: `Compose inbound sessions from the "${presetName}" agent preset.`, tags: ['preset'] }]
|
|
107
|
+
}
|
|
108
|
+
return [{ id: 'chat', name: 'chat', description: 'Conversational assistance over a DSH agent session.', tags: ['chat'] }]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Assemble one live instance from a persisted record. */
|
|
112
|
+
function assemble(record: InboundServerRecord, host: InboundManagerHost): LiveInboundServer {
|
|
113
|
+
const skills: readonly AgentSkill[] = record.skills
|
|
114
|
+
const endpointPath = record.endpointPath
|
|
115
|
+
const cardPath = `${endpointPath.replace(/\/$/, '')}/agent-card.json`
|
|
116
|
+
const card = buildCard({
|
|
117
|
+
baseUrl: host.defaultBaseUrl,
|
|
118
|
+
endpointPath,
|
|
119
|
+
name: record.name,
|
|
120
|
+
description: record.description,
|
|
121
|
+
version: record.version,
|
|
122
|
+
skills,
|
|
123
|
+
...(record.authTokenEnv !== undefined && process.env[record.authTokenEnv] !== undefined
|
|
124
|
+
? { authToken: process.env[record.authTokenEnv]! }
|
|
125
|
+
: {}),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// One preset-bound session pool per instance.
|
|
129
|
+
const sessionPool = host.agents
|
|
130
|
+
? new ContextSessionPool(host.agents, {
|
|
131
|
+
cwd: host.sessionCwd,
|
|
132
|
+
...(host.agentPresets !== undefined ? { agentPresets: host.agentPresets } : {}),
|
|
133
|
+
...(record.preset !== undefined ? { presetId: () => record.preset } : {}),
|
|
134
|
+
// `?.()` yields `{ provider?, model? } | undefined`, matching the
|
|
135
|
+
// AgentRuntimeOptions.resolveAgentOptions signature under
|
|
136
|
+
// exactOptionalPropertyTypes.
|
|
137
|
+
resolveAgentOptions: () => host.resolveDefaultModel?.(),
|
|
138
|
+
})
|
|
139
|
+
: undefined
|
|
140
|
+
const sessionExecutor = sessionPool ? createSessionExecutor(sessionPool) : refuseExecutor('no agent loop mounted')
|
|
141
|
+
const subagentExecutor = sessionPool !== undefined && host.subagents !== undefined
|
|
142
|
+
? createSubagentExecutor({ pool: sessionPool, subagents: host.subagents, provider: host.subagentProvider })
|
|
143
|
+
: undefined
|
|
144
|
+
const executors = new ExecutorSet({ chat: 'session' }, sessionExecutor, subagentExecutor)
|
|
145
|
+
|
|
146
|
+
const inbound = new LiveInboundRegistry()
|
|
147
|
+
const skillIds = new Set(skills.map((s) => s.id))
|
|
148
|
+
const gate = async (input: GateInput): Promise<GateResult> => {
|
|
149
|
+
if (!skillIds.has(input.skill)) {
|
|
150
|
+
return { ok: false, reason: `unknown skill "${input.skill}"` }
|
|
151
|
+
}
|
|
152
|
+
const decision: InboundTaskDecision = {
|
|
153
|
+
contextId: input.contextId,
|
|
154
|
+
skill: input.skill,
|
|
155
|
+
parts: input.parts,
|
|
156
|
+
remotePeerId: input.remotePeerId,
|
|
157
|
+
}
|
|
158
|
+
const decided = await host.ctx.waterfall('a2a/inbound-task', decision, async (d) => d)
|
|
159
|
+
host.logger.info(`[a2a:${record.id}] inbound skill=${decided.skill} rejected=${decided.rejected?.reason ?? 'no'}`)
|
|
160
|
+
if (decided.rejected !== undefined) return { ok: false, reason: decided.rejected.reason }
|
|
161
|
+
return { ok: true }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const server = new A2AServer({
|
|
165
|
+
card,
|
|
166
|
+
store: host.tasks,
|
|
167
|
+
executors,
|
|
168
|
+
...(card.securitySchemes !== undefined ? { authToken: 'configured' } : {}),
|
|
169
|
+
cardPath,
|
|
170
|
+
gate,
|
|
171
|
+
onInbound: (facts) => inbound.note({
|
|
172
|
+
method: facts.method,
|
|
173
|
+
...(facts.source !== undefined ? { source: facts.source } : {}),
|
|
174
|
+
taskIds: facts.taskIds,
|
|
175
|
+
streaming: facts.streaming,
|
|
176
|
+
}),
|
|
177
|
+
onTaskSettled: (taskId) => {
|
|
178
|
+
host.logger.info(`[a2a:${record.id}] task settled ${taskId}`)
|
|
179
|
+
inbound.settle(taskId)
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
const routes = new A2aRoutes(host.webServer, server, cardPath)
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
id: record.id,
|
|
186
|
+
record,
|
|
187
|
+
card,
|
|
188
|
+
server,
|
|
189
|
+
routes,
|
|
190
|
+
inbound,
|
|
191
|
+
executors,
|
|
192
|
+
endpointPath,
|
|
193
|
+
cardPath,
|
|
194
|
+
dispose: () => {
|
|
195
|
+
routes.dispose()
|
|
196
|
+
void executors.disposeAll().catch(() => {})
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Persisted-instance store over the inbound_servers table. */
|
|
202
|
+
export class DomainInboundStore {
|
|
203
|
+
private static KEY(id: string): string {
|
|
204
|
+
return `in:${id}`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
constructor(private readonly table: {
|
|
208
|
+
get(key: string): string | undefined
|
|
209
|
+
put(key: string, value: string): Promise<void>
|
|
210
|
+
entries?(): IterableIterator<[string, string]>
|
|
211
|
+
keys?(): IterableIterator<string>
|
|
212
|
+
}) {}
|
|
213
|
+
|
|
214
|
+
list(): InboundServerRecord[] {
|
|
215
|
+
const out: InboundServerRecord[] = []
|
|
216
|
+
const entries = this.table.entries
|
|
217
|
+
if (entries === undefined) return out
|
|
218
|
+
// Call through the table (method `this` stays bound); the real KvTable's
|
|
219
|
+
// `entries()` reads instance state.
|
|
220
|
+
for (const [key, raw] of entries.call(this.table)) {
|
|
221
|
+
if (!key.startsWith('in:')) continue
|
|
222
|
+
const parsed = safeParse(raw)
|
|
223
|
+
if (parsed !== undefined) out.push(parsed)
|
|
224
|
+
}
|
|
225
|
+
return out
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
get(id: string): InboundServerRecord | undefined {
|
|
229
|
+
const raw = this.table.get(DomainInboundStore.KEY(id))
|
|
230
|
+
return raw === undefined ? undefined : safeParse(raw)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async save(record: InboundServerRecord): Promise<void> {
|
|
234
|
+
await this.table.put(DomainInboundStore.KEY(record.id), JSON.stringify(record))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async remove(id: string): Promise<void> {
|
|
238
|
+
await this.table.put(DomainInboundStore.KEY(id), '')
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function safeParse(raw: string): InboundServerRecord | undefined {
|
|
243
|
+
try {
|
|
244
|
+
const parsed = JSON.parse(raw) as InboundServerRecord
|
|
245
|
+
if (typeof parsed.id !== 'string' || typeof parsed.name !== 'string' || !Array.isArray(parsed.skills)) return undefined
|
|
246
|
+
return parsed
|
|
247
|
+
} catch {
|
|
248
|
+
return undefined
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Manages the live set of inbound server instances. */
|
|
253
|
+
export class InboundServerManager {
|
|
254
|
+
private readonly live = new Map<string, LiveInboundServer>()
|
|
255
|
+
|
|
256
|
+
constructor(
|
|
257
|
+
private readonly host: InboundManagerHost,
|
|
258
|
+
private readonly store: DomainInboundStore,
|
|
259
|
+
) {}
|
|
260
|
+
|
|
261
|
+
/** Assemble every persisted enabled instance (boot). */
|
|
262
|
+
boot(): void {
|
|
263
|
+
for (const record of this.store.list()) {
|
|
264
|
+
try {
|
|
265
|
+
const live = assemble(record, this.host)
|
|
266
|
+
this.live.set(record.id, live)
|
|
267
|
+
if (record.enabled) live.routes.enable()
|
|
268
|
+
} catch (err) {
|
|
269
|
+
this.host.logger.error(`[a2a:${record.id}] failed to assemble: ${String((err as Error).message)}`)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
list(): readonly LiveInboundServer[] {
|
|
275
|
+
return [...this.live.values()]
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
get(id: string): LiveInboundServer | undefined {
|
|
279
|
+
return this.live.get(id)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Resolve the display name of a preset id (undefined when unnamed/absent). */
|
|
283
|
+
private async presetDisplayName(preset: string | undefined): Promise<string | undefined> {
|
|
284
|
+
if (preset === undefined || this.host.agentPresets === undefined) return undefined
|
|
285
|
+
try {
|
|
286
|
+
const resolved = await this.host.agentPresets.resolve(preset)
|
|
287
|
+
return resolved.name ?? resolved.id
|
|
288
|
+
} catch {
|
|
289
|
+
return undefined
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Create, persist, and assemble a new inbound instance. */
|
|
294
|
+
async add(input: InboundCreateInput): Promise<OpResult & { readonly id?: string }> {
|
|
295
|
+
const id = this.newId(input.name)
|
|
296
|
+
if (this.live.has(id)) return { ok: false, message: `inbound server ${id} already exists` }
|
|
297
|
+
const presetName = await this.presetDisplayName(input.preset)
|
|
298
|
+
const record: InboundServerRecord = {
|
|
299
|
+
id,
|
|
300
|
+
name: input.name,
|
|
301
|
+
description: input.description,
|
|
302
|
+
version: input.version,
|
|
303
|
+
endpointPath: input.endpointPath ?? `/a2a/${id}`,
|
|
304
|
+
...(input.preset !== undefined ? { preset: input.preset } : {}),
|
|
305
|
+
...(input.authTokenEnv !== undefined ? { authTokenEnv: input.authTokenEnv } : {}),
|
|
306
|
+
skills: [...defaultSkillFor(input.skills, presetName)],
|
|
307
|
+
enabled: input.enabled ?? true,
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const live = assemble(record, this.host)
|
|
311
|
+
await this.store.save(record)
|
|
312
|
+
this.live.set(id, live)
|
|
313
|
+
if (record.enabled) live.routes.enable()
|
|
314
|
+
return { ok: true, message: `inbound server "${record.name}" created`, id }
|
|
315
|
+
} catch (err) {
|
|
316
|
+
return { ok: false, message: `failed to create inbound server: ${String((err as Error).message)}` }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Persist, rebuild, and apply a patch onto a live instance. */
|
|
321
|
+
async update(id: string, patch: Partial<Pick<InboundServerRecord, 'name' | 'description' | 'version' | 'endpointPath' | 'preset' | 'authTokenEnv' | 'skills'>>): Promise<OpResult> {
|
|
322
|
+
const live = this.live.get(id)
|
|
323
|
+
const stored = this.store.get(id)
|
|
324
|
+
if (live === undefined || stored === undefined) return { ok: false, message: `inbound server ${id} not found` }
|
|
325
|
+
const next: InboundServerRecord = {
|
|
326
|
+
...stored,
|
|
327
|
+
name: patch.name ?? stored.name,
|
|
328
|
+
description: patch.description ?? stored.description,
|
|
329
|
+
version: patch.version ?? stored.version,
|
|
330
|
+
endpointPath: patch.endpointPath ?? stored.endpointPath,
|
|
331
|
+
...(patch.preset !== undefined ? { preset: patch.preset } : {}),
|
|
332
|
+
...(patch.authTokenEnv !== undefined ? { authTokenEnv: patch.authTokenEnv } : {}),
|
|
333
|
+
skills: patch.skills ?? stored.skills,
|
|
334
|
+
enabled: stored.enabled,
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
await this.store.save(next)
|
|
338
|
+
const replaced = assemble(next, this.host)
|
|
339
|
+
// Preserve the route registration state while swapping runtime halves.
|
|
340
|
+
const wasEnabled = live.routes.active
|
|
341
|
+
live.dispose()
|
|
342
|
+
this.live.set(id, replaced)
|
|
343
|
+
if (wasEnabled || next.enabled) replaced.routes.enable()
|
|
344
|
+
return { ok: true, message: `inbound server ${id} updated` }
|
|
345
|
+
} catch (err) {
|
|
346
|
+
return { ok: false, message: `failed to update inbound server: ${String((err as Error).message)}` }
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Enable/disable an instance's routes. */
|
|
351
|
+
setEnabled(id: string, enabled: boolean): OpResult {
|
|
352
|
+
const live = this.live.get(id)
|
|
353
|
+
if (live === undefined) return { ok: false, message: `inbound server ${id} not found` }
|
|
354
|
+
if (enabled) live.routes.enable()
|
|
355
|
+
else live.routes.disable()
|
|
356
|
+
const stored = this.store.get(id)
|
|
357
|
+
if (stored !== undefined) {
|
|
358
|
+
void this.store.save({ ...stored, enabled }).catch(() => {})
|
|
359
|
+
}
|
|
360
|
+
return { ok: true, message: `inbound server ${id} ${enabled ? 'enabled' : 'disabled'}` }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Remove, dispose, and unpersist an instance. */
|
|
364
|
+
async remove(id: string): Promise<OpResult> {
|
|
365
|
+
const live = this.live.get(id)
|
|
366
|
+
if (live === undefined) return { ok: false, message: `inbound server ${id} not found` }
|
|
367
|
+
live.dispose()
|
|
368
|
+
this.live.delete(id)
|
|
369
|
+
await this.store.remove(id)
|
|
370
|
+
return { ok: true, message: `inbound server ${id} removed` }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Dispose every instance. */
|
|
374
|
+
disposeAll(): void {
|
|
375
|
+
for (const live of this.live.values()) live.dispose()
|
|
376
|
+
this.live.clear()
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private newId(seed: string): string {
|
|
380
|
+
const slug = seed.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'server'
|
|
381
|
+
return `${slug}-${this.host.newId()}`
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export type { AgentSkill }
|