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,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The membership ledger: which SESSIONS of this instance belong to which groups.
|
|
3
|
+
*
|
|
4
|
+
* The unit of membership is a **Session**, not the instance. That distinction is
|
|
5
|
+
* the whole reason this file exists in its current shape:
|
|
6
|
+
*
|
|
7
|
+
* - one person runs several Sessions on one machine (workspaces, or several
|
|
8
|
+
* conversations in one workspace), and each may take part in a different group;
|
|
9
|
+
* - a group post must land in the Session that belongs to that group, not in
|
|
10
|
+
* whichever Session happened to call first;
|
|
11
|
+
* - the same group can legitimately be joined TWICE on one machine — a person
|
|
12
|
+
* responsible for two modules of one project is two members in that project's
|
|
13
|
+
* group, with two credentials and two unread cursors.
|
|
14
|
+
*
|
|
15
|
+
* So the key is `(sessionId, groupId)`. Keying on `groupId` alone was the earlier
|
|
16
|
+
* design and it made every group belong to the whole instance, which is not how
|
|
17
|
+
* people work.
|
|
18
|
+
*
|
|
19
|
+
* Why the credential lives here rather than in `groups.js`: a member credential is
|
|
20
|
+
* handed out **exactly once** (by `create`/`join`/`claim`), and the store never
|
|
21
|
+
* returns it again — the right design for a secret, but it means the caller has to
|
|
22
|
+
* keep it. This ledger is that caller. It is also where the per-membership read
|
|
23
|
+
* watermark lives, because "I am member X in group Y and have read up to N" is one
|
|
24
|
+
* fact about one membership.
|
|
25
|
+
*
|
|
26
|
+
* Threat model, stated plainly: `personKey` is cleartext in a 0600 file. It has to
|
|
27
|
+
* be — the plugin must present it on every call, so any local encryption would need
|
|
28
|
+
* its key stored on the same machine, which protects nothing against the threat
|
|
29
|
+
* that matters (another process on this machine). 0600 does protect against other
|
|
30
|
+
* users of a shared box. Hashing locally, as the group host does, is impossible for
|
|
31
|
+
* the same reason the host can hash: the host only verifies, while we must present.
|
|
32
|
+
*
|
|
33
|
+
* @module dsh-a2a/memberships
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
37
|
+
import { dirname, join } from 'node:path';
|
|
38
|
+
|
|
39
|
+
/** File name inside the plugin's home directory. */
|
|
40
|
+
const FILE_NAME = 'memberships.json';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The `sessionId` used by a HOST that holds a group on behalf of others.
|
|
44
|
+
*
|
|
45
|
+
* A pure group host runs no model and therefore has no Session to deliver into. It
|
|
46
|
+
* still needs a membership record — that record is what carries the owner
|
|
47
|
+
* credential and the read watermark — so it is filed under this reserved id.
|
|
48
|
+
*
|
|
49
|
+
* Consequences, which are exactly what a host wants:
|
|
50
|
+
*
|
|
51
|
+
* - nothing is ever delivered to it (there is no Session to deliver to);
|
|
52
|
+
* - a host and a real Session cannot collide, because a real Session id is a
|
|
53
|
+
* uuid-shaped string and this is a fixed sentinel;
|
|
54
|
+
* - a member instance never uses it, because its memberships are created from a
|
|
55
|
+
* tool call that has a Session.
|
|
56
|
+
*/
|
|
57
|
+
export const HOST_SESSION_ID = 'host';
|
|
58
|
+
|
|
59
|
+
/** Upper bound on remembered memberships, so a runaway join loop cannot grow the file. */
|
|
60
|
+
const MAX_MEMBERSHIPS = 500;
|
|
61
|
+
|
|
62
|
+
/** Stable key for one (session, group) pair. `\u0000` cannot appear in either id. */
|
|
63
|
+
function keyOf(sessionId, groupId) {
|
|
64
|
+
return `${sessionId}\u0000${groupId}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One membership record: what this instance knows about a Session in a group.
|
|
69
|
+
*
|
|
70
|
+
* Every field here is written by `upsert` and read by the panel, so this type is
|
|
71
|
+
* the ledger's contract rather than a description of one call site. `personKey`
|
|
72
|
+
* is the member credential and never leaves the process; the rest is either
|
|
73
|
+
* addresses for A2A calls or cached facts for a panel that cannot make them.
|
|
74
|
+
*
|
|
75
|
+
* @typedef {object} MembershipRecord
|
|
76
|
+
* @property {string} sessionId - the Session this membership belongs to.
|
|
77
|
+
* @property {string} groupId - the group, as its host named it.
|
|
78
|
+
* @property {string} [personId] - this person's id inside that group.
|
|
79
|
+
* @property {string} [personKey] - member credential; plaintext, never published.
|
|
80
|
+
* @property {string} [baseUrl] - host endpoint, for a remote group.
|
|
81
|
+
* @property {boolean} [local] - true when this instance hosts the group.
|
|
82
|
+
* @property {string} [groupName] - cached name, for a panel rendered offline.
|
|
83
|
+
* @property {string} [role] - `owner` or `member`, as the host reported it.
|
|
84
|
+
* @property {number} [lastSeq] - read watermark, for the unread count.
|
|
85
|
+
* @property {number} [touchedAt] - epoch ms of the last write.
|
|
86
|
+
* @property {object[]} [members] - cached roster, newest first.
|
|
87
|
+
* @property {number} [rosterCachedAt] - epoch ms the roster was cached.
|
|
88
|
+
* @property {object[]} [feed] - cached feed tail for a local group.
|
|
89
|
+
* @property {string} [groupTopic]
|
|
90
|
+
* @property {number} [memberCount]
|
|
91
|
+
* @property {number} [onlineCount]
|
|
92
|
+
* @property {number} [lastTime]
|
|
93
|
+
* @property {boolean} [isOwner]
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The filter `list()` accepts. Both fields are optional and combine.
|
|
98
|
+
*
|
|
99
|
+
* @typedef {object} MembershipFilter
|
|
100
|
+
* @property {string} [sessionId]
|
|
101
|
+
* @property {string} [groupId]
|
|
102
|
+
*/
|
|
103
|
+
/**
|
|
104
|
+
* Build the ledger.
|
|
105
|
+
*
|
|
106
|
+
* @param {object} options - construction options.
|
|
107
|
+
* @param {string} [options.home] - plugin home directory (`<DSH_HOME>/a2a`).
|
|
108
|
+
* @param {object} [options.logger] - host logger.
|
|
109
|
+
* @param {Function} [options.clock] - epoch-millisecond clock.
|
|
110
|
+
* @returns {object} the ledger.
|
|
111
|
+
*/
|
|
112
|
+
export function createMembershipLedger({ home, logger, clock = () => Date.now() } = {}) {
|
|
113
|
+
if (typeof home !== 'string' || home === '') throw new Error('dsh-a2a: the membership ledger needs a home directory');
|
|
114
|
+
const file = join(home, FILE_NAME);
|
|
115
|
+
/** `${sessionId}\u0000${groupId}` → record */
|
|
116
|
+
const records = load();
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Every membership, most recently touched first.
|
|
120
|
+
*
|
|
121
|
+
* Returned as copies: a caller must not be able to change a credential by
|
|
122
|
+
* assigning to a field of a view it was handed.
|
|
123
|
+
*
|
|
124
|
+
* @param {MembershipFilter} [filter] - one field, the other, or both.
|
|
125
|
+
* @returns {MembershipRecord[]} matching records.
|
|
126
|
+
*/
|
|
127
|
+
function list(filter = {}) {
|
|
128
|
+
const wantSession = typeof filter.sessionId === 'string' ? filter.sessionId : undefined;
|
|
129
|
+
const wantGroup = typeof filter.groupId === 'string' ? filter.groupId : undefined;
|
|
130
|
+
return [...records.values()]
|
|
131
|
+
.filter((record) => (wantSession === undefined || record.sessionId === wantSession)
|
|
132
|
+
&& (wantGroup === undefined || record.groupId === wantGroup))
|
|
133
|
+
.sort((left, right) => (right.touchedAt ?? 0) - (left.touchedAt ?? 0))
|
|
134
|
+
.map((record) => ({ ...record }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One membership.
|
|
139
|
+
*
|
|
140
|
+
* `get(groupId)` (one argument) is the "any session" form, kept for the paths that
|
|
141
|
+
* only need to know that SOME session on this instance belongs to the group —
|
|
142
|
+
* `a2a_groups` and the inbox's own routing both start from a record they already
|
|
143
|
+
* have, so they always pass the session. Passing it is strongly preferred: with
|
|
144
|
+
* one argument the answer depends on iteration order when two Sessions joined the
|
|
145
|
+
* same group, which is exactly the ambiguity the key was designed to remove.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} groupId - group id.
|
|
148
|
+
* @param {string} [sessionId] - the Session; omit only for an existence check.
|
|
149
|
+
* @returns {object|undefined} the record.
|
|
150
|
+
*/
|
|
151
|
+
function get(groupId, sessionId) {
|
|
152
|
+
if (typeof sessionId === 'string' && sessionId !== '') {
|
|
153
|
+
const record = records.get(keyOf(sessionId, groupId));
|
|
154
|
+
return record === undefined ? undefined : { ...record };
|
|
155
|
+
}
|
|
156
|
+
const candidates = list({ groupId });
|
|
157
|
+
return candidates.length === 0 ? undefined : candidates[0];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Whether this instance is a member of the group at all, from any Session. */
|
|
161
|
+
function has(groupId) {
|
|
162
|
+
return list({ groupId }).length > 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Every membership of one Session. */
|
|
166
|
+
function membershipsOf(sessionId) {
|
|
167
|
+
return list({ sessionId });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Groups this instance hosts itself (a hosted group is joined locally). */
|
|
171
|
+
function localMemberships() {
|
|
172
|
+
return list().filter((record) => record.local === true);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Memberships that need a subscription: the group lives somewhere else. */
|
|
176
|
+
function remoteMemberships() {
|
|
177
|
+
return list().filter((record) => record.local !== true);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Insert or update one membership.
|
|
182
|
+
*
|
|
183
|
+
* A partial update is supported on purpose: the inbox updates `lastSeq` on every
|
|
184
|
+
* post, and asking it to re-supply the credential each time would mean the
|
|
185
|
+
* credential travels through one more function than it has to.
|
|
186
|
+
*
|
|
187
|
+
* `sessionId` is required — it is the first half of the key. A caller with no
|
|
188
|
+
* Session (a pure group host, which runs no model) passes {@link HOST_SESSION_ID}.
|
|
189
|
+
* Accepting a missing id instead would create a membership that can never be
|
|
190
|
+
* delivered to and can collide with another such record.
|
|
191
|
+
*
|
|
192
|
+
* @param {object} input - `{sessionId, groupId, personId, personKey, baseUrl, local, groupName, role, lastSeq}`.
|
|
193
|
+
* @returns {object} the stored record.
|
|
194
|
+
*/
|
|
195
|
+
function upsert(input = {}) {
|
|
196
|
+
const groupId = typeof input.groupId === 'string' ? input.groupId : '';
|
|
197
|
+
if (groupId === '') throw new Error('dsh-a2a: a membership needs a groupId');
|
|
198
|
+
const sessionId = typeof input.sessionId === 'string' ? input.sessionId : '';
|
|
199
|
+
if (sessionId === '') {
|
|
200
|
+
throw new Error(`dsh-a2a: a membership needs a sessionId (use "${HOST_SESSION_ID}" for a host that has no Session)`);
|
|
201
|
+
}
|
|
202
|
+
const key = keyOf(sessionId, groupId);
|
|
203
|
+
const previous = records.get(key) ?? {};
|
|
204
|
+
const record = {
|
|
205
|
+
sessionId,
|
|
206
|
+
groupId,
|
|
207
|
+
personId: input.personId ?? previous.personId,
|
|
208
|
+
personKey: input.personKey ?? previous.personKey,
|
|
209
|
+
// `local` means the group is hosted by this very instance, so a call never
|
|
210
|
+
// leaves the process. Kept on the record because the answer decides the code
|
|
211
|
+
// path (a local call has no gateway and no network failure mode).
|
|
212
|
+
local: typeof input.local === 'boolean' ? input.local : previous.local === true,
|
|
213
|
+
baseUrl: input.baseUrl ?? previous.baseUrl,
|
|
214
|
+
groupName: input.groupName ?? previous.groupName,
|
|
215
|
+
role: input.role ?? previous.role ?? 'member',
|
|
216
|
+
lastSeq: typeof input.lastSeq === 'number' ? input.lastSeq : previous.lastSeq ?? 0,
|
|
217
|
+
// A cached view of the group, filled by `a2a_group_info`. Kept on the record
|
|
218
|
+
// because the panel renders from this ledger, not from the network: without it a
|
|
219
|
+
// remote group shows an id and "0 人", which reads as an empty group rather than
|
|
220
|
+
// one that has simply not been fetched yet.
|
|
221
|
+
groupTopic: input.groupTopic ?? previous.groupTopic,
|
|
222
|
+
memberCount: typeof input.memberCount === 'number' ? input.memberCount : previous.memberCount,
|
|
223
|
+
onlineCount: typeof input.onlineCount === 'number' ? input.onlineCount : previous.onlineCount,
|
|
224
|
+
lastTime: typeof input.lastTime === 'number' ? input.lastTime : previous.lastTime,
|
|
225
|
+
isOwner: typeof input.isOwner === 'boolean' ? input.isOwner : previous.isOwner,
|
|
226
|
+
feed: Array.isArray(input.feed) ? input.feed : previous.feed,
|
|
227
|
+
// The roster a Session fetched with a member credential. Cached so the panel can
|
|
228
|
+
// draw people without a network call, and stamped so a stale list is visibly stale.
|
|
229
|
+
members: Array.isArray(input.members) ? input.members : previous.members,
|
|
230
|
+
rosterCachedAt: typeof input.rosterCachedAt === 'number' ? input.rosterCachedAt : previous.rosterCachedAt,
|
|
231
|
+
addedAt: previous.addedAt ?? clock(),
|
|
232
|
+
touchedAt: clock(),
|
|
233
|
+
};
|
|
234
|
+
records.set(key, record);
|
|
235
|
+
// Bound the file: an instance that joins hundreds of groups has a bug, and a
|
|
236
|
+
// silently unbounded state file is worse than forgetting an ancient one.
|
|
237
|
+
if (records.size > MAX_MEMBERSHIPS) {
|
|
238
|
+
const oldest = [...records.values()].sort((left, right) => left.touchedAt - right.touchedAt);
|
|
239
|
+
for (const stale of oldest.slice(0, records.size - MAX_MEMBERSHIPS)) {
|
|
240
|
+
records.delete(keyOf(stale.sessionId, stale.groupId));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
persist();
|
|
244
|
+
return { ...record };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Forget one membership, or every membership of one Session.
|
|
249
|
+
*
|
|
250
|
+
* The Session-wide form is what the caller uses when a Session is archived or
|
|
251
|
+
* deleted: its memberships are still in the groups, but nothing here could ever
|
|
252
|
+
* deliver to it again, so keeping the credentials would be a leak with no use.
|
|
253
|
+
*
|
|
254
|
+
* @param {object} where - `{groupId, sessionId?}` or `{sessionId}`.
|
|
255
|
+
* @returns {number} how many records were removed.
|
|
256
|
+
*/
|
|
257
|
+
function remove(where = {}) {
|
|
258
|
+
const wantGroup = typeof where.groupId === 'string' ? where.groupId : undefined;
|
|
259
|
+
const wantSession = typeof where.sessionId === 'string' ? where.sessionId : undefined;
|
|
260
|
+
if (wantGroup === undefined && wantSession === undefined) return 0;
|
|
261
|
+
let removed = 0;
|
|
262
|
+
for (const record of [...records.values()]) {
|
|
263
|
+
if (wantGroup !== undefined && record.groupId !== wantGroup) continue;
|
|
264
|
+
if (wantSession !== undefined && record.sessionId !== wantSession) continue;
|
|
265
|
+
records.delete(keyOf(record.sessionId, record.groupId));
|
|
266
|
+
removed += 1;
|
|
267
|
+
}
|
|
268
|
+
if (removed > 0) persist();
|
|
269
|
+
return removed;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Write the file now. Never throws: a ledger that cannot be written still works in memory. */
|
|
273
|
+
function flush() {
|
|
274
|
+
persist();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function persist() {
|
|
278
|
+
const payload = JSON.stringify({
|
|
279
|
+
version: 2,
|
|
280
|
+
savedAt: new Date(clock()).toISOString(),
|
|
281
|
+
memberships: [...records.values()],
|
|
282
|
+
}, null, 2);
|
|
283
|
+
try {
|
|
284
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
285
|
+
// Write beside the target and rename: a crash mid-write must not leave a
|
|
286
|
+
// half-file that reads as "this instance has no groups".
|
|
287
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
288
|
+
writeFileSync(temporary, payload, { encoding: 'utf8', mode: 0o600 });
|
|
289
|
+
renameSync(temporary, file);
|
|
290
|
+
try {
|
|
291
|
+
chmodSync(file, 0o600);
|
|
292
|
+
} catch {
|
|
293
|
+
// Windows ignores POSIX modes; the rename above is what matters there.
|
|
294
|
+
}
|
|
295
|
+
} catch (error) {
|
|
296
|
+
logger?.warn?.(`a2a: could not persist memberships (${error instanceof Error ? error.message : String(error)})`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function load() {
|
|
301
|
+
const map = new Map();
|
|
302
|
+
let raw;
|
|
303
|
+
try {
|
|
304
|
+
raw = readFileSync(file, 'utf8');
|
|
305
|
+
} catch {
|
|
306
|
+
return map;
|
|
307
|
+
}
|
|
308
|
+
let parsed;
|
|
309
|
+
try {
|
|
310
|
+
parsed = JSON.parse(raw);
|
|
311
|
+
} catch {
|
|
312
|
+
logger?.warn?.('a2a: memberships.json is not valid JSON; starting with no memberships (the file is left in place)');
|
|
313
|
+
return map;
|
|
314
|
+
}
|
|
315
|
+
// Version 1 records had no `sessionId` (membership was instance-wide). They are
|
|
316
|
+
// dropped rather than guessed at: there is no correct Session to attach them to,
|
|
317
|
+
// and a wrong guess would deliver somebody else's group posts into this one.
|
|
318
|
+
if (parsed?.version !== 2) {
|
|
319
|
+
logger?.info?.('a2a: memberships.json is from an older version and had instance-wide memberships; starting fresh (re-join your groups)');
|
|
320
|
+
return map;
|
|
321
|
+
}
|
|
322
|
+
const entries = Array.isArray(parsed?.memberships) ? parsed.memberships : [];
|
|
323
|
+
for (const entry of entries) {
|
|
324
|
+
if (typeof entry?.groupId !== 'string' || entry.groupId === '') continue;
|
|
325
|
+
if (typeof entry?.sessionId !== 'string' || entry.sessionId === '') continue;
|
|
326
|
+
map.set(keyOf(entry.sessionId, entry.groupId), {
|
|
327
|
+
sessionId: entry.sessionId,
|
|
328
|
+
groupId: entry.groupId,
|
|
329
|
+
personId: typeof entry.personId === 'string' ? entry.personId : undefined,
|
|
330
|
+
personKey: typeof entry.personKey === 'string' ? entry.personKey : undefined,
|
|
331
|
+
local: entry.local === true,
|
|
332
|
+
baseUrl: typeof entry.baseUrl === 'string' ? entry.baseUrl : undefined,
|
|
333
|
+
groupName: typeof entry.groupName === 'string' ? entry.groupName : undefined,
|
|
334
|
+
role: typeof entry.role === 'string' ? entry.role : 'member',
|
|
335
|
+
lastSeq: Number.isFinite(entry.lastSeq) ? entry.lastSeq : 0,
|
|
336
|
+
// Cached group view (see `upsert`): restored so a restart does not blank the
|
|
337
|
+
// panel back to ids and zeros.
|
|
338
|
+
groupTopic: typeof entry.groupTopic === 'string' ? entry.groupTopic : undefined,
|
|
339
|
+
memberCount: Number.isFinite(entry.memberCount) ? entry.memberCount : undefined,
|
|
340
|
+
onlineCount: Number.isFinite(entry.onlineCount) ? entry.onlineCount : undefined,
|
|
341
|
+
lastTime: Number.isFinite(entry.lastTime) ? entry.lastTime : undefined,
|
|
342
|
+
isOwner: typeof entry.isOwner === 'boolean' ? entry.isOwner : undefined,
|
|
343
|
+
feed: Array.isArray(entry.feed) ? entry.feed : undefined,
|
|
344
|
+
members: Array.isArray(entry.members) ? entry.members : undefined,
|
|
345
|
+
rosterCachedAt: Number.isFinite(entry.rosterCachedAt) ? entry.rosterCachedAt : undefined,
|
|
346
|
+
addedAt: Number.isFinite(entry.addedAt) ? entry.addedAt : 0,
|
|
347
|
+
touchedAt: Number.isFinite(entry.touchedAt) ? entry.touchedAt : 0,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return map;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
list,
|
|
355
|
+
get,
|
|
356
|
+
has,
|
|
357
|
+
membershipsOf,
|
|
358
|
+
localMemberships,
|
|
359
|
+
remoteMemberships,
|
|
360
|
+
upsert,
|
|
361
|
+
remove,
|
|
362
|
+
flush,
|
|
363
|
+
/** The file this ledger lives in, for diagnostics. */
|
|
364
|
+
path: () => file,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-check: is this DSH instance reachable as an A2A agent through the gateway?
|
|
3
|
+
*
|
|
4
|
+
* Why this exists, and why it is not the admin API: the question a person
|
|
5
|
+
* actually has is "will my colleague's invite link work", and the only honest way
|
|
6
|
+
* to answer it is to make the same call a colleague would. Reading the gateway's
|
|
7
|
+
* configured agent list proves the config was *written*, not that the route
|
|
8
|
+
* *works* — a wrong secret, a wrong URL, or a disabled entry all look fine in the
|
|
9
|
+
* config and fail at the door.
|
|
10
|
+
*
|
|
11
|
+
* The probe therefore walks the real path, in order, and stops at the first step
|
|
12
|
+
* that fails so the report names the actual obstacle:
|
|
13
|
+
*
|
|
14
|
+
* 1. Is the local server attached? (no gateway involved)
|
|
15
|
+
* 2. Does the gateway serve our card? (auth + ACL + upstream reach)
|
|
16
|
+
* 3. Does the card come back rewritten? (the gateway is really in front)
|
|
17
|
+
* 4. Does a real skill answer through it? (the whole chain)
|
|
18
|
+
*
|
|
19
|
+
* Step 1 needs no network, so an instance that is up but not yet registered gets
|
|
20
|
+
* "your side is fine, the gateway entry is what is missing" instead of a generic
|
|
21
|
+
* failure.
|
|
22
|
+
*
|
|
23
|
+
* @module dsh-a2a/probe
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** How long one probe request may take, in milliseconds. */
|
|
27
|
+
const PROBE_TIMEOUT_MS = 8000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check whether this instance is reachable through a gateway.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} options - probe inputs.
|
|
33
|
+
* @param {object} [options.config] - normalized plugin config.
|
|
34
|
+
* @param {object} [options.server] - the server facade (for `info()`).
|
|
35
|
+
* @param {object} [options.fetchImpl] - fetch implementation, for tests.
|
|
36
|
+
* @returns {Promise<object>} a report: `{ok, checks, hint}`.
|
|
37
|
+
*/
|
|
38
|
+
export async function probeReachability({ config, server, fetchImpl } = {}) {
|
|
39
|
+
const doFetch = typeof fetchImpl === 'function' ? fetchImpl : globalThis.fetch;
|
|
40
|
+
const checks = [];
|
|
41
|
+
|
|
42
|
+
// --- 1. the local side ---------------------------------------------------
|
|
43
|
+
const info = typeof server?.info === 'function' ? server.info() : undefined;
|
|
44
|
+
const localUp = info?.attached === true;
|
|
45
|
+
checks.push({
|
|
46
|
+
step: 'local',
|
|
47
|
+
ok: localUp,
|
|
48
|
+
detail: localUp
|
|
49
|
+
? `this instance serves ${info.url}`
|
|
50
|
+
: 'the A2A server is not attached (server.enabled is false, or webServer is unavailable)',
|
|
51
|
+
});
|
|
52
|
+
if (!localUp) {
|
|
53
|
+
return finish(checks, 'Start the A2A server first: set server.enabled = true and restart.');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- what the gateway should be asked for -------------------------------
|
|
57
|
+
const gatewayBase = config?.groups?.gatewayBase ?? config?.client?.gatewayBase ?? '';
|
|
58
|
+
const agentName = config?.groups?.agentName ?? '';
|
|
59
|
+
if (gatewayBase === '' || agentName === '') {
|
|
60
|
+
checks.push({
|
|
61
|
+
step: 'config',
|
|
62
|
+
ok: false,
|
|
63
|
+
detail: gatewayBase === ''
|
|
64
|
+
? 'groups.gatewayBase is not set'
|
|
65
|
+
: 'groups.agentName is not set (the name this instance has inside the gateway; one name, all groups)',
|
|
66
|
+
});
|
|
67
|
+
return finish(checks, 'Fill in groups.gatewayBase and groups.agentName, then run this check again.');
|
|
68
|
+
}
|
|
69
|
+
checks.push({ step: 'config', ok: true, detail: `gateway ${gatewayBase}, agent "${agentName}"` });
|
|
70
|
+
|
|
71
|
+
// --- 2. the card through the gateway ------------------------------------
|
|
72
|
+
const cardUrl = `${gatewayBase}/${agentName}/.well-known/agent-card.json`;
|
|
73
|
+
const headers = {};
|
|
74
|
+
if (config?.client?.apiKey) headers['x-api-key'] = config.client.apiKey;
|
|
75
|
+
// The gateway assumes https unless told otherwise, and a wrong scheme makes the
|
|
76
|
+
// card it hands back unusable; this header is what the local http setup needs.
|
|
77
|
+
if (config?.client?.forwardProto) headers['x-forwarded-proto'] = config.client.forwardProto;
|
|
78
|
+
|
|
79
|
+
let card;
|
|
80
|
+
try {
|
|
81
|
+
const response = await withTimeout(doFetch, cardUrl, { headers }, PROBE_TIMEOUT_MS);
|
|
82
|
+
if (response.status === 401 || response.status === 403) {
|
|
83
|
+
checks.push({
|
|
84
|
+
step: 'gateway-auth',
|
|
85
|
+
ok: false,
|
|
86
|
+
detail: `the gateway refused our key (HTTP ${response.status}); this is client.apiKey, not server.token`,
|
|
87
|
+
});
|
|
88
|
+
return finish(checks, 'The gateway rejected the caller key. Check client.apiKey against the gateway\'s api_keys.');
|
|
89
|
+
}
|
|
90
|
+
if (response.status === 404) {
|
|
91
|
+
checks.push({
|
|
92
|
+
step: 'gateway-registered',
|
|
93
|
+
ok: false,
|
|
94
|
+
detail: `the gateway knows no agent named "${agentName}"`,
|
|
95
|
+
});
|
|
96
|
+
return finish(checks, registrationHint(gatewayBase, agentName, info));
|
|
97
|
+
}
|
|
98
|
+
if (response.status !== 200) {
|
|
99
|
+
checks.push({ step: 'gateway', ok: false, detail: `the gateway answered HTTP ${response.status}` });
|
|
100
|
+
return finish(checks, 'The gateway could not reach this instance. Check that its URL uses an address the gateway can actually dial.');
|
|
101
|
+
}
|
|
102
|
+
card = await response.json();
|
|
103
|
+
checks.push({ step: 'gateway-registered', ok: true, detail: `card served at ${cardUrl}` });
|
|
104
|
+
} catch (error) {
|
|
105
|
+
checks.push({ step: 'gateway', ok: false, detail: describe(error) });
|
|
106
|
+
return finish(checks, 'The gateway is unreachable from here. Check groups.gatewayBase.');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- 3. the card must point back at the gateway -------------------------
|
|
110
|
+
const advertised = String(card?.url ?? '');
|
|
111
|
+
const rewritten = advertised.includes(`/a2a/${agentName}`) || advertised.startsWith(gatewayBase);
|
|
112
|
+
checks.push({
|
|
113
|
+
step: 'card-rewritten',
|
|
114
|
+
// A card still advertising the upstream address means the caller would bypass
|
|
115
|
+
// the gateway; it also leaks an internal address, which is why it is a
|
|
116
|
+
// failure rather than a warning.
|
|
117
|
+
ok: rewritten,
|
|
118
|
+
detail: rewritten ? `advertised url: ${advertised}` : `card advertises "${advertised}", which is not the gateway`,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// --- 4. a real skill through the whole chain ---------------------------
|
|
122
|
+
const skills = Array.isArray(card?.skills) ? card.skills.map((skill) => skill?.id) : [];
|
|
123
|
+
if (!skills.includes('ping')) {
|
|
124
|
+
checks.push({ step: 'skill', ok: false, detail: 'the card does not advertise "ping", so the chain cannot be proven' });
|
|
125
|
+
return finish(checks, 'Register this plugin\'s server.backgrounded skills, or check that server.skills are enabled.');
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const response = await withTimeout(doFetch, `${gatewayBase}/${agentName}`, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { ...headers, 'content-type': 'application/json', 'a2a-version': '1.0' },
|
|
131
|
+
body: JSON.stringify({
|
|
132
|
+
jsonrpc: '2.0',
|
|
133
|
+
id: `probe-${Date.now()}`,
|
|
134
|
+
method: 'SendMessage',
|
|
135
|
+
params: {
|
|
136
|
+
message: {
|
|
137
|
+
role: 'user',
|
|
138
|
+
messageId: `probe-${Date.now()}`,
|
|
139
|
+
parts: [{ kind: 'text', text: 'ping' }],
|
|
140
|
+
metadata: { skill: 'ping' },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
}, PROBE_TIMEOUT_MS);
|
|
145
|
+
const body = await response.json().catch(() => undefined);
|
|
146
|
+
const text = (body?.result?.task?.artifacts ?? [])
|
|
147
|
+
.flatMap((artifact) => artifact.parts ?? [])
|
|
148
|
+
.map((part) => part.text ?? '')
|
|
149
|
+
.join('');
|
|
150
|
+
const answered = response.status === 200 && text !== '';
|
|
151
|
+
checks.push({
|
|
152
|
+
step: 'roundtrip',
|
|
153
|
+
ok: answered,
|
|
154
|
+
detail: answered
|
|
155
|
+
? `ping answered through the gateway: ${text.replace(/\s+/g, ' ').slice(0, 80)}`
|
|
156
|
+
: `the gateway answered HTTP ${response.status}${body?.error ? ` (${body.error.code}: ${body.error.message})` : ''}`,
|
|
157
|
+
});
|
|
158
|
+
} catch (error) {
|
|
159
|
+
checks.push({ step: 'roundtrip', ok: false, detail: describe(error) });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return finish(checks, undefined);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Assemble the report and the one-line advice that goes with it. */
|
|
166
|
+
function finish(checks, hint) {
|
|
167
|
+
const ok = checks.every((check) => check.ok);
|
|
168
|
+
const failed = checks.filter((check) => !check.ok);
|
|
169
|
+
return {
|
|
170
|
+
ok,
|
|
171
|
+
checks,
|
|
172
|
+
hint: ok
|
|
173
|
+
? 'Ready: a colleague\'s invite link will work as soon as you mint one.'
|
|
174
|
+
: (hint ?? failed[0]?.detail ?? 'One step failed; see the checks above.'),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The gateway entry this instance needs, spelled out.
|
|
180
|
+
*
|
|
181
|
+
* Printed only when the gateway does not know the name, because that is the one
|
|
182
|
+
* case where the operator genuinely has to edit a file — and it happens once per
|
|
183
|
+
* INSTANCE, not once per group.
|
|
184
|
+
*/
|
|
185
|
+
export function registrationHint(gatewayBase, agentName, info) {
|
|
186
|
+
const token = info?.tokenConfigured === true ? '<this profile\'s server.token>' : '<set server.token first>';
|
|
187
|
+
return [
|
|
188
|
+
`The gateway has no agent named "${agentName}". Register this instance ONCE (not per group):`,
|
|
189
|
+
'',
|
|
190
|
+
'a2a_agents:',
|
|
191
|
+
` - name: "${agentName}"`,
|
|
192
|
+
` url: "${info?.url ?? 'http://127.0.0.1:3091/a2a'}"`,
|
|
193
|
+
` protocol_version: "1.0"`,
|
|
194
|
+
' auth_type: api_key',
|
|
195
|
+
` secret: "${token}"`,
|
|
196
|
+
' timeout_ms: 600000',
|
|
197
|
+
' enabled: true',
|
|
198
|
+
'',
|
|
199
|
+
`Then reload the gateway and run this check again. Every group you create afterwards`,
|
|
200
|
+
`reuses this one entry — the group id travels in the request, not in the URL.`,
|
|
201
|
+
].join('\n');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Fetch with a deadline, so a wedged gateway cannot hang the tool that asked. */
|
|
205
|
+
async function withTimeout(fetchImpl, url, init, timeoutMs) {
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
208
|
+
try {
|
|
209
|
+
return await fetchImpl(url, { ...init, signal: controller.signal });
|
|
210
|
+
} finally {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** One line about an error, without a stack. */
|
|
216
|
+
function describe(error) {
|
|
217
|
+
if (error?.name === 'AbortError') return 'timed out';
|
|
218
|
+
return error instanceof Error ? error.message : String(error);
|
|
219
|
+
}
|