@nelsonlongxiang/dsh-open-a2a-net 0.5.14 → 0.6.24
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 +243 -121
- package/README_zh.md +144 -84
- package/assets/nexusDist/assets/index-034h1cNc.js +3 -0
- package/assets/nexusDist/assets/index-PG9EieGX.css +1 -0
- package/assets/nexusDist/index.html +26 -0
- package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -0
- package/assets/stageDist/assets/index-C-28k_o5.js +974 -0
- package/assets/stageDist/assets/interiors-CQGBjffT.png +0 -0
- package/assets/stageDist/assets/office-tileset-CHQT-uYL.png +0 -0
- package/assets/stageDist/index.html +13 -0
- package/lib/a2a-client.js +190 -13
- package/lib/canvas-store.js +218 -0
- package/lib/card.js +25 -0
- package/lib/client/A2aControl.js +45 -137
- package/lib/client/locales.js +16 -40
- package/lib/client.js +164 -467
- package/lib/client.js.map +1 -1
- package/lib/group-store.js +6 -0
- package/lib/idempotency-store.js +291 -0
- package/lib/index.js +4217 -259
- package/lib/joined-store.js +11 -0
- package/lib/layout-store.js +155 -0
- package/lib/peer-store.js +98 -13
- package/lib/receipt-ladder.js +35 -0
- package/lib/receipt.js +102 -0
- package/lib/self-suppress.js +62 -0
- package/lib/session-transcript.js +150 -0
- package/lib/skill/SKILL.md +77 -0
- package/lib/skill/references/discovery.md +38 -0
- package/lib/skill/references/membership.md +50 -0
- package/lib/skill/references/pitfalls.md +42 -0
- package/lib/skill/references/routing.md +43 -0
- package/lib/skill-registration.js +52 -0
- package/lib/stage-mount.js +32 -0
- package/lib/task-ledger.js +540 -0
- package/lib/team-store.js +122 -0
- package/lib/teams-bridge.js +18 -0
- package/lib/transport-caps.js +31 -0
- package/lib/types/a2a-client.d.ts +72 -1
- package/lib/types/canvas-store.d.ts +102 -0
- package/lib/types/client/A2aControl.d.ts +18 -28
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/group-store.d.ts +6 -0
- package/lib/types/idempotency-store.d.ts +164 -0
- package/lib/types/index.d.ts +210 -6
- package/lib/types/joined-store.d.ts +9 -0
- package/lib/types/layout-store.d.ts +63 -0
- package/lib/types/peer-store.d.ts +45 -2
- package/lib/types/receipt-ladder.d.ts +25 -0
- package/lib/types/receipt.d.ts +51 -0
- package/lib/types/self-suppress.d.ts +41 -0
- package/lib/types/session-transcript.d.ts +37 -0
- package/lib/types/skill-registration.d.ts +3 -0
- package/lib/types/stage-mount.d.ts +29 -0
- package/lib/types/task-ledger.d.ts +308 -0
- package/lib/types/team-store.d.ts +55 -0
- package/lib/types/teams-bridge.d.ts +112 -0
- package/lib/types/transport-caps.d.ts +29 -0
- package/lib/types/types.d.ts +43 -0
- package/nexus-stage/package-lock.json +2262 -0
- package/nexus-stage/package.json +21 -0
- package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
- package/nexus-stage/scripts/mockhost.py +253 -0
- package/nexus-stage/src/canvas-ops.ts +175 -0
- package/nexus-stage/src/canvas-wire.ts +156 -0
- package/nexus-stage/src/edges.ts +72 -0
- package/nexus-stage/src/fault.ts +56 -0
- package/nexus-stage/src/federation.ts +234 -0
- package/nexus-stage/src/layout-doc.ts +143 -0
- package/nexus-stage/src/layout-wire.ts +158 -0
- package/nexus-stage/src/main.ts +361 -0
- package/nexus-stage/src/planning-menus.ts +352 -0
- package/nexus-stage/src/planning-nodelist.ts +294 -0
- package/nexus-stage/src/planning-types.ts +111 -0
- package/nexus-stage/src/planning-view.ts +1313 -0
- package/nexus-stage/src/planning.css +280 -0
- package/nexus-stage/src/seat.ts +35 -0
- package/nexus-stage/src/stage-name.ts +13 -0
- package/nexus-stage/src/tokens.ts +32 -0
- package/nexus-stage/src/viewport.ts +83 -0
- package/nexus-stage/src/workbench.css +267 -0
- package/nexus-stage/src/workbench.ts +1530 -0
- package/nexus-stage/src/world.ts +509 -0
- package/nexus-stage/tsconfig.json +25 -0
- package/nexus-stage/vite.config.ts +16 -0
- package/package.json +83 -22
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>dsh-a2a-munder-difflin · A2A Floor</title>
|
|
7
|
+
<script type="module" crossorigin src="./assets/index-C-28k_o5.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="./assets/index-Bj7S2pA1.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/lib/a2a-client.js
CHANGED
|
@@ -6,8 +6,15 @@
|
|
|
6
6
|
* @module @nelsonlongxiang/dsh-open-a2a-net/a2a-client
|
|
7
7
|
*/
|
|
8
8
|
import { verifyCard } from "./card.js";
|
|
9
|
+
import { MAX_ROUTE_BODY_BYTES, WIRE_ERROR_PAYLOAD_TOO_LARGE, withinRouteBodyCap } from "./transport-caps.js";
|
|
9
10
|
/** Direct-route HTTP budget. */
|
|
10
11
|
const HTTP_TIMEOUT_MS = 15_000;
|
|
12
|
+
/** The native-teams bridge marker, when the peer's answer carries one: its
|
|
13
|
+
* rounds emit no A2A receipt in this slice, so the caller must not book the
|
|
14
|
+
* task as receipt-owed. */
|
|
15
|
+
function wireBridge(value) {
|
|
16
|
+
return value === 'native-teams' ? 'native-teams' : undefined;
|
|
17
|
+
}
|
|
11
18
|
/** Wire strings are absent-or-string; anything else degrades to ''. */
|
|
12
19
|
function wireText(value) {
|
|
13
20
|
return typeof value === 'string' ? value : '';
|
|
@@ -28,13 +35,26 @@ export class A2aClient {
|
|
|
28
35
|
headers['Content-Type'] = 'application/json';
|
|
29
36
|
if (this.options.apiKey !== '')
|
|
30
37
|
headers['X-API-Key'] = this.options.apiKey;
|
|
38
|
+
// GETs are card fetches: name ourselves so the peer serves the full
|
|
39
|
+
// card (gh#7 G1). POSTs carry caller_session in the body already.
|
|
40
|
+
if (!json && this.options.cardCaller !== '')
|
|
41
|
+
headers['X-A2A-Caller'] = this.options.cardCaller;
|
|
42
|
+
// gh#9+gh#10: the OriginClaim envelope — a verifiable origin on every
|
|
43
|
+
// peer request when the signer is wired (full-card reads and delivery
|
|
44
|
+
// verification both ride it).
|
|
45
|
+
if (this.options.originClaim !== undefined) {
|
|
46
|
+
for (const [key, value] of Object.entries(this.options.originClaim()))
|
|
47
|
+
headers[key] = value;
|
|
48
|
+
}
|
|
31
49
|
return headers;
|
|
32
50
|
}
|
|
33
51
|
async http(path, init, baseUrl) {
|
|
34
52
|
if (init.signal?.aborted)
|
|
35
53
|
throw new Error('A2A request aborted before dispatch');
|
|
36
54
|
const controller = new AbortController();
|
|
55
|
+
let ownBudgetFired = false;
|
|
37
56
|
const timer = this.options.schedule(() => {
|
|
57
|
+
ownBudgetFired = true;
|
|
38
58
|
controller.abort();
|
|
39
59
|
}, HTTP_TIMEOUT_MS);
|
|
40
60
|
const abort = () => {
|
|
@@ -49,10 +69,26 @@ export class A2aClient {
|
|
|
49
69
|
signal: controller.signal,
|
|
50
70
|
});
|
|
51
71
|
const text = await response.text();
|
|
52
|
-
if (!response.ok)
|
|
53
|
-
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
// Carry the status and raw body on the error so callers can read the
|
|
74
|
+
// peer's structured wire code (idempotency verdicts) without parsing
|
|
75
|
+
// the prose of the message.
|
|
76
|
+
const error = new Error(`A2A HTTP ${String(response.status)}: ${text.slice(0, 200)}`);
|
|
77
|
+
error.statusCode = response.status;
|
|
78
|
+
error.bodyText = text;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
54
81
|
return JSON.parse(text);
|
|
55
82
|
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
// Tag the escapee so the caller can tell our budget firing from an
|
|
85
|
+
// external signal — the abortElapsedMs telemetry needs the author.
|
|
86
|
+
if (ownBudgetFired && error instanceof Error) {
|
|
87
|
+
;
|
|
88
|
+
error.ownBudgetExhausted = true;
|
|
89
|
+
}
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
56
92
|
finally {
|
|
57
93
|
timer();
|
|
58
94
|
init.signal?.removeEventListener('abort', abort);
|
|
@@ -67,16 +103,28 @@ export class A2aClient {
|
|
|
67
103
|
* @returns the verified card, or `undefined` when unreachable, malformed, or rejected.
|
|
68
104
|
*/
|
|
69
105
|
async fetchCard(baseUrl) {
|
|
106
|
+
const outcome = await this.fetchCardDetail(baseUrl);
|
|
107
|
+
return outcome.ok ? outcome.card : undefined;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The diagnosing half of {@link fetchCard}: the same fetch and verification
|
|
111
|
+
* with the failure stage preserved, so a probe can tell a down node from a
|
|
112
|
+
* distrusted card.
|
|
113
|
+
* @param baseUrl - the peer's base URL.
|
|
114
|
+
* @returns the verified card, or the failure's stage and detail.
|
|
115
|
+
*/
|
|
116
|
+
async fetchCardDetail(baseUrl) {
|
|
70
117
|
try {
|
|
71
118
|
const candidate = await this.http('/.well-known/agent-card.json', { method: 'GET' }, baseUrl);
|
|
72
119
|
const verified = verifyCard(candidate, Date.now());
|
|
73
120
|
if (!verified.ok)
|
|
74
|
-
return
|
|
75
|
-
return verified.card;
|
|
121
|
+
return { ok: false, stage: 'rejected', reason: verified.reason };
|
|
122
|
+
return { ok: true, card: verified.card };
|
|
76
123
|
}
|
|
77
|
-
catch {
|
|
78
|
-
// An unreachable or malformed peer leaves discovery to the other peers
|
|
79
|
-
|
|
124
|
+
catch (error) {
|
|
125
|
+
// An unreachable or malformed peer leaves discovery to the other peers;
|
|
126
|
+
// the detail names what actually happened for the probe.
|
|
127
|
+
return { ok: false, stage: 'unreachable', detail: String(error).slice(0, 200) };
|
|
80
128
|
}
|
|
81
129
|
}
|
|
82
130
|
/**
|
|
@@ -106,7 +154,7 @@ export class A2aClient {
|
|
|
106
154
|
* @param callerSession - caller label overriding the node's label (the calling session's node label).
|
|
107
155
|
* @returns the canonical route result (success or explicit failure).
|
|
108
156
|
*/
|
|
109
|
-
async routeDirect(baseUrl, team, message, contextId, signal, callerSession, asyncMode = false, taskIdFromCaller) {
|
|
157
|
+
async routeDirect(baseUrl, team, message, contextId, signal, callerSession, asyncMode = false, taskIdFromCaller, callbackAddress) {
|
|
110
158
|
const args = { team, message, caller_session: callerSession ?? this.options.sessionId };
|
|
111
159
|
if (contextId !== undefined)
|
|
112
160
|
args.context_id = contextId;
|
|
@@ -120,16 +168,80 @@ export class A2aClient {
|
|
|
120
168
|
// own (pre-0.5.3 peers).
|
|
121
169
|
if (taskIdFromCaller !== undefined)
|
|
122
170
|
args.task_id = taskIdFromCaller;
|
|
171
|
+
// The callback address (P2): where the peer routes THIS caller's
|
|
172
|
+
// receipt — distinct from the caller label when the submitting session
|
|
173
|
+
// has its own node team (the transport face's callbackTarget mapping
|
|
174
|
+
// rides this). Old peers ignore the extra body field harmlessly.
|
|
175
|
+
if (callbackAddress !== undefined && callbackAddress !== '')
|
|
176
|
+
args.callback = callbackAddress;
|
|
177
|
+
// Outbound half of the same B5 ruling: refuse to put an oversized
|
|
178
|
+
// payload on the wire. Rejection is local and instant — a doomed upload
|
|
179
|
+
// would burn the 15s HTTP budget and end in the peer's 413 anyway.
|
|
180
|
+
const body = JSON.stringify(args);
|
|
181
|
+
if (!withinRouteBodyCap(Buffer.byteLength(body, 'utf8'))) {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
error: `payload exceeds the ${String(MAX_ROUTE_BODY_BYTES)}-byte direct-route transport cap`,
|
|
185
|
+
code: WIRE_ERROR_PAYLOAD_TOO_LARGE,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
123
188
|
let raw;
|
|
189
|
+
const dispatchedAt = Date.now();
|
|
124
190
|
try {
|
|
125
191
|
raw = await this.http('/a2a/direct', {
|
|
126
192
|
method: 'POST',
|
|
127
|
-
body
|
|
193
|
+
body,
|
|
128
194
|
...(signal !== undefined ? { signal } : {}),
|
|
129
195
|
}, baseUrl);
|
|
130
196
|
}
|
|
131
197
|
catch (error) {
|
|
132
|
-
|
|
198
|
+
const ownBudgetExhausted = error.ownBudgetExhausted === true;
|
|
199
|
+
// F1 half-transaction fix (t-mth5gmxy): the 15s budget firing on a SYNC
|
|
200
|
+
// route does NOT mean the peer failed — the delivery landed and the
|
|
201
|
+
// peer keeps executing. The structured -32005 code marks the
|
|
202
|
+
// DELIVERED-UNSETTLED shape; the caller side (index.ts, which owns the
|
|
203
|
+
// fingerprint and the peer candidates) runs the query compensation and
|
|
204
|
+
// upgrades or confirms the honest result there. The budget gate checks
|
|
205
|
+
// BOTH the flag and the error KIND: an immediate transport rejection
|
|
206
|
+
// (ECONNREFUSED) racing a scheduled timer is a transport failure, not
|
|
207
|
+
// a wait timeout — only a genuine abort-class error is the budget.
|
|
208
|
+
const abortClass = error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError' || /abort/i.test(error.message));
|
|
209
|
+
if (ownBudgetExhausted && abortClass && !asyncMode && !signal?.aborted) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
error: `route wait timed out after ${String(HTTP_TIMEOUT_MS)}ms but the delivery was dispatched and may still complete — reconcile via the task query (do NOT re-dispatch blindly)`,
|
|
213
|
+
code: -32005,
|
|
214
|
+
task_id: taskIdFromCaller,
|
|
215
|
+
abortElapsedMs: Date.now() - dispatchedAt,
|
|
216
|
+
ownBudgetExhausted: true,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
// The peer's structured wire code rides the HTTP rejection body
|
|
220
|
+
// (idempotency verdicts -32002/-32003 among others): surface it, so a
|
|
221
|
+
// caller can tell a terminal verdict from an ordinary transport miss
|
|
222
|
+
// without parsing prose.
|
|
223
|
+
let code = -32000;
|
|
224
|
+
const bodyText = error.bodyText;
|
|
225
|
+
if (typeof bodyText === 'string') {
|
|
226
|
+
try {
|
|
227
|
+
const parsed = JSON.parse(bodyText);
|
|
228
|
+
if (typeof parsed.code === 'number')
|
|
229
|
+
code = parsed.code;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
/* prose body — keep the generic code */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const failure = {
|
|
236
|
+
ok: false,
|
|
237
|
+
error: `direct route to peer failed: ${String(error)}`,
|
|
238
|
+
code,
|
|
239
|
+
// Wait-window telemetry (envelope-v2 §4): measured patience plus
|
|
240
|
+
// whose budget ended it. Pure observation, zero semantics.
|
|
241
|
+
abortElapsedMs: Date.now() - dispatchedAt,
|
|
242
|
+
...(ownBudgetExhausted ? { ownBudgetExhausted: true } : {}),
|
|
243
|
+
};
|
|
244
|
+
return failure;
|
|
133
245
|
}
|
|
134
246
|
if (raw.error !== undefined && raw.error !== null) {
|
|
135
247
|
const failure = {
|
|
@@ -141,26 +253,91 @@ export class A2aClient {
|
|
|
141
253
|
return failure;
|
|
142
254
|
}
|
|
143
255
|
// The delivered shape (wait:false): no result member — the reply rides
|
|
144
|
-
// the receipt contract back to the caller's team instead.
|
|
256
|
+
// the receipt contract back to the caller's team instead. A bridged
|
|
257
|
+
// native round carries the peer's `bridge` marker and promises NO
|
|
258
|
+
// receipt: the text must not claim one the slice will never send.
|
|
145
259
|
if (raw.routed === true && raw.delivered === true) {
|
|
146
260
|
const taskId = wireText(raw.task_id) !== '' ? wireText(raw.task_id) : taskIdFromCaller ?? '';
|
|
261
|
+
const bridge = wireBridge(raw.bridge);
|
|
262
|
+
// F1 client half (async-stall §三): the peer's consumed probe rides
|
|
263
|
+
// through — a delivered-but-unconsumed message is queued (busy turn)
|
|
264
|
+
// or latched, and the caller should follow up, not assume arrival.
|
|
265
|
+
const consumed = typeof raw.consumed === 'boolean' ? raw.consumed : undefined;
|
|
147
266
|
return {
|
|
148
267
|
ok: true,
|
|
149
268
|
team,
|
|
150
|
-
reply:
|
|
269
|
+
reply: bridge !== undefined
|
|
270
|
+
? `Delivered to ${team} (async native-teams round). It settles through the team's own routing chain and routes no A2A receipt in this slice — reconcile via context_id follow-ups.`
|
|
271
|
+
: consumed === false
|
|
272
|
+
? `Delivered to ${team} (async), but the target had not started a turn on it yet — the message is queued behind the session's current turn. Watch a2a_status activity or follow up with the context id; do not resend.`
|
|
273
|
+
: `Delivered to ${team} (async). The target routes a receipt — a message starting "[A2A receipt] task ${taskId} <outcome summary>" — back to your team when done; watch a2a_status activity or follow up with the context id.`,
|
|
151
274
|
task_id: taskId,
|
|
152
275
|
context_id: wireText(raw.context_id),
|
|
153
276
|
task_status: wireText(raw.task_status) === '' ? 'TASK_STATE_DELIVERED' : wireText(raw.task_status),
|
|
277
|
+
...(consumed !== undefined ? { consumed } : {}),
|
|
278
|
+
...(bridge !== undefined ? { bridge } : {}),
|
|
154
279
|
};
|
|
155
280
|
}
|
|
156
281
|
const reply = this.replyText(raw.result, '');
|
|
282
|
+
const bridge = wireBridge(raw.bridge);
|
|
157
283
|
return {
|
|
158
284
|
ok: true,
|
|
159
285
|
team,
|
|
160
286
|
reply,
|
|
161
|
-
|
|
287
|
+
// Same echo-first rule as the delivered branch: the peer's echo wins,
|
|
288
|
+
// else the caller-born id, else ''.
|
|
289
|
+
task_id: wireText(raw.task_id) !== '' ? wireText(raw.task_id) : taskIdFromCaller ?? '',
|
|
162
290
|
context_id: wireText(raw.context_id),
|
|
163
291
|
task_status: wireText(raw.task_status),
|
|
292
|
+
...(bridge !== undefined ? { bridge } : {}),
|
|
164
293
|
};
|
|
165
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Query a peer's `/a2a/query` outcome surface (W7 slice 2): read-only
|
|
297
|
+
* lookup of one claimed task's settled outcome. The fingerprint is the
|
|
298
|
+
* authorization — the caller recomputes it from the exact submit fields
|
|
299
|
+
* with the shared `peerPayloadFingerprint`. Every transport-level failure
|
|
300
|
+
* (network miss, HTTP error, unparseable or malformed body, empty fields)
|
|
301
|
+
* answers `undefined`: a query that cannot complete carries no increment
|
|
302
|
+
* of information, never a verdict.
|
|
303
|
+
*/
|
|
304
|
+
async queryOutcome(baseUrl, taskId, fingerprint, signal) {
|
|
305
|
+
if (taskId === '' || fingerprint === '')
|
|
306
|
+
return undefined;
|
|
307
|
+
try {
|
|
308
|
+
const raw = await this.http('/a2a/query', {
|
|
309
|
+
method: 'POST',
|
|
310
|
+
body: JSON.stringify({ task_id: taskId, fingerprint }),
|
|
311
|
+
...(signal !== undefined ? { signal } : {}),
|
|
312
|
+
}, baseUrl);
|
|
313
|
+
return parseQueryAnswer(raw);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** Coerce one raw wire body into an {@link A2aQueryAnswer}; anything
|
|
321
|
+
* malformed degrades to `undefined` (no verdict), never to a guess. */
|
|
322
|
+
function parseQueryAnswer(raw) {
|
|
323
|
+
if (raw.found !== true && raw.found !== false)
|
|
324
|
+
return undefined;
|
|
325
|
+
if (raw.found === false) {
|
|
326
|
+
if (raw.reason !== 'unknown-task' && raw.reason !== 'payload-mismatch')
|
|
327
|
+
return undefined;
|
|
328
|
+
return { found: false, reason: raw.reason };
|
|
329
|
+
}
|
|
330
|
+
if (raw.status === 'pending')
|
|
331
|
+
return { found: true, status: 'pending' };
|
|
332
|
+
const settledAt = typeof raw.settled_at === 'string' && raw.settled_at !== '' ? raw.settled_at : undefined;
|
|
333
|
+
if (settledAt === undefined)
|
|
334
|
+
return undefined;
|
|
335
|
+
const truncated = raw.truncated === true ? { truncated: true } : {};
|
|
336
|
+
if (raw.status === 'completed' && typeof raw.reply === 'string') {
|
|
337
|
+
return { found: true, status: 'completed', reply: raw.reply, settledAt, ...truncated };
|
|
338
|
+
}
|
|
339
|
+
if (raw.status === 'failed' && typeof raw.error === 'string') {
|
|
340
|
+
return { found: true, status: 'failed', error: raw.error, settledAt, ...truncated };
|
|
341
|
+
}
|
|
342
|
+
return undefined;
|
|
166
343
|
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted canvas teams: user-composed multi-member routing groups under
|
|
3
|
+
* the a2a home (`a2a/canvas.json`). A canvas team is a named set of member
|
|
4
|
+
* references in one of two shapes: a local session id (a joined session
|
|
5
|
+
* node on this host) or a remote team handle (`<zone>/<id8>`, a session
|
|
6
|
+
* node published by a peer — one member may sit in many teams). Routing to
|
|
7
|
+
* `<team>/canvas/<name>` resolves the first live local member, wakes the
|
|
8
|
+
* first cold local one, then falls through to the remote members in order
|
|
9
|
+
* (the directory resolves their handles; remote nodes never wake from
|
|
10
|
+
* here). Membership order is the routing priority (member list order),
|
|
11
|
+
* preserved verbatim on disk. Plain JSON, one writer (the control routes),
|
|
12
|
+
* read fresh by the state route.
|
|
13
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/canvas-store
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname } from 'node:path';
|
|
17
|
+
/** Upper bounds: a canvas taxonomy, not a database. */
|
|
18
|
+
export const CANVAS_TEAM_CAP = 64;
|
|
19
|
+
export const CANVAS_MEMBER_CAP = 32;
|
|
20
|
+
/** Longest accepted team name (same budget as group names). */
|
|
21
|
+
export const CANVAS_NAME_MAX = 40;
|
|
22
|
+
/**
|
|
23
|
+
* Whether one member entry references a remote node rather than a local
|
|
24
|
+
* session id. Remote refs are zone-qualified team handles (`<zone>/<id8>`),
|
|
25
|
+
* so the '/'-test is deterministic: local session ids (`session-<uuid>`,
|
|
26
|
+
* `import-<uuid>`) never contain one, and canvas team names — the only other
|
|
27
|
+
* strings in a member slot — are barred from '/' at creation.
|
|
28
|
+
*/
|
|
29
|
+
export function isRemoteMemberRef(id) {
|
|
30
|
+
return id.includes('/');
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Shape-gate for a remote member reference at the control-route seam: at
|
|
34
|
+
* least one '/'-delimited segment on each side, no whitespace. The registry
|
|
35
|
+
* cross-check lives in the UI (the picker offers discovered rows only);
|
|
36
|
+
* this gate keeps malformed strings out of the persisted file.
|
|
37
|
+
*/
|
|
38
|
+
export function validRemoteMemberRef(id) {
|
|
39
|
+
const trimmed = id.trim();
|
|
40
|
+
return trimmed === id && isRemoteMemberRef(id) && !/\s/.test(id)
|
|
41
|
+
&& !id.startsWith('/') && !id.endsWith('/') && !id.includes('//');
|
|
42
|
+
}
|
|
43
|
+
function validName(raw) {
|
|
44
|
+
const clean = raw.trim();
|
|
45
|
+
if (clean === '' || clean.length > CANVAS_NAME_MAX)
|
|
46
|
+
return undefined;
|
|
47
|
+
// '/' would nest the wire namespace (<team>/canvas/a/b); pure digits would
|
|
48
|
+
// reorder under JSON.parse's integer-like key ordering if the store ever
|
|
49
|
+
// migrated to a keyed map — both stay out now.
|
|
50
|
+
if (clean.includes('/') || /^\d+$/.test(clean))
|
|
51
|
+
return undefined;
|
|
52
|
+
return clean;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Bounded canvas-team store with last-write-wins persistence. Pure of any
|
|
56
|
+
* registry knowledge: callers validate join state and resolve members.
|
|
57
|
+
*/
|
|
58
|
+
export class CanvasStore {
|
|
59
|
+
/** Team entries in creation order; members in add order (routing priority). */
|
|
60
|
+
teams = [];
|
|
61
|
+
path;
|
|
62
|
+
/**
|
|
63
|
+
* @param path - the snapshot file; an empty path keeps the store in memory.
|
|
64
|
+
*/
|
|
65
|
+
constructor(path) {
|
|
66
|
+
this.path = path;
|
|
67
|
+
this.restore();
|
|
68
|
+
}
|
|
69
|
+
/** Load a persisted snapshot, if present and well-formed. */
|
|
70
|
+
restore() {
|
|
71
|
+
if (this.path === '' || !existsSync(this.path))
|
|
72
|
+
return;
|
|
73
|
+
try {
|
|
74
|
+
const snapshot = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
75
|
+
if (!Array.isArray(snapshot?.teams))
|
|
76
|
+
return;
|
|
77
|
+
const kept = [];
|
|
78
|
+
for (const entry of snapshot.teams) {
|
|
79
|
+
if (entry === null || typeof entry !== 'object')
|
|
80
|
+
continue;
|
|
81
|
+
if (validName(String(entry.name)) === undefined)
|
|
82
|
+
continue;
|
|
83
|
+
const members = Array.isArray(entry.members)
|
|
84
|
+
? entry.members.filter((id) => typeof id === 'string' && id !== '').slice(0, CANVAS_MEMBER_CAP)
|
|
85
|
+
: [];
|
|
86
|
+
if (kept.some(t => t.name === entry.name))
|
|
87
|
+
continue;
|
|
88
|
+
kept.push({ name: String(entry.name), members });
|
|
89
|
+
}
|
|
90
|
+
this.teams = kept.slice(0, CANVAS_TEAM_CAP);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// A corrupt store is not fatal: the user re-creates teams; routing
|
|
94
|
+
// names simply stop resolving until then.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Persist the current snapshot (no-op without a path). */
|
|
98
|
+
persist() {
|
|
99
|
+
if (this.path === '')
|
|
100
|
+
return;
|
|
101
|
+
try {
|
|
102
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
103
|
+
const snapshot = { teams: this.teams.map(t => ({ name: t.name, members: [...t.members] })) };
|
|
104
|
+
writeFileSync(this.path, JSON.stringify(snapshot), { mode: 0o600 });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// An unwritable home must not break the canvas; the store degrades to
|
|
108
|
+
// memory-only.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Every team name, in creation order. */
|
|
112
|
+
list() {
|
|
113
|
+
return this.teams.map(t => t.name);
|
|
114
|
+
}
|
|
115
|
+
/** Whether the named team exists. */
|
|
116
|
+
hasTeam(name) {
|
|
117
|
+
return this.teams.some(t => t.name === name);
|
|
118
|
+
}
|
|
119
|
+
/** The named team's member ids in routing-priority order (a copy). */
|
|
120
|
+
membersOf(name) {
|
|
121
|
+
const entry = this.teams.find(t => t.name === name);
|
|
122
|
+
return entry === undefined ? [] : [...entry.members];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The named team's remote member refs (zone-qualified team handles) in
|
|
126
|
+
* routing-priority order — the failover tier after the local live/cold
|
|
127
|
+
* tiers miss. Shape test only: whether the handle currently resolves is
|
|
128
|
+
* the directory's question at route time, not the store's.
|
|
129
|
+
*/
|
|
130
|
+
remoteMembersOf(name) {
|
|
131
|
+
return this.membersOf(name).filter(isRemoteMemberRef);
|
|
132
|
+
}
|
|
133
|
+
/** Every team name the id is a member of, in team creation order. */
|
|
134
|
+
teamsOf(id) {
|
|
135
|
+
return this.teams.filter(t => t.members.includes(id)).map(t => t.name);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Create a named team; whitespace trims, duplicates are idempotent, and
|
|
139
|
+
* the cap rejects a team beyond {@link CANVAS_TEAM_CAP}.
|
|
140
|
+
* @param name - the raw user-supplied name.
|
|
141
|
+
* @returns the stored name, or undefined when rejected.
|
|
142
|
+
*/
|
|
143
|
+
create(name) {
|
|
144
|
+
const clean = validName(name);
|
|
145
|
+
if (clean === undefined)
|
|
146
|
+
return undefined;
|
|
147
|
+
if (this.hasTeam(clean))
|
|
148
|
+
return clean;
|
|
149
|
+
if (this.teams.length >= CANVAS_TEAM_CAP)
|
|
150
|
+
return undefined;
|
|
151
|
+
this.teams.push({ name: clean, members: [] });
|
|
152
|
+
this.persist();
|
|
153
|
+
return clean;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Delete a team (with its memberships).
|
|
157
|
+
* @param name - the team name.
|
|
158
|
+
*/
|
|
159
|
+
remove(name) {
|
|
160
|
+
const index = this.teams.findIndex(t => t.name === name);
|
|
161
|
+
if (index < 0)
|
|
162
|
+
return false;
|
|
163
|
+
this.teams.splice(index, 1);
|
|
164
|
+
this.persist();
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Add one member (idempotent; duplicates keep the original priority slot).
|
|
169
|
+
* The team must exist — callers create it first so the cap applies once.
|
|
170
|
+
* @param name - the existing team name.
|
|
171
|
+
* @param id - the member session id.
|
|
172
|
+
*/
|
|
173
|
+
addMember(name, id) {
|
|
174
|
+
if (id === '')
|
|
175
|
+
return false;
|
|
176
|
+
const entry = this.teams.find(t => t.name === name);
|
|
177
|
+
if (entry === undefined)
|
|
178
|
+
return false;
|
|
179
|
+
if (entry.members.includes(id))
|
|
180
|
+
return true;
|
|
181
|
+
if (entry.members.length >= CANVAS_MEMBER_CAP)
|
|
182
|
+
return false;
|
|
183
|
+
entry.members.push(id);
|
|
184
|
+
this.persist();
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Remove one member from one team (a no-op when absent).
|
|
189
|
+
*/
|
|
190
|
+
removeMember(name, id) {
|
|
191
|
+
const entry = this.teams.find(t => t.name === name);
|
|
192
|
+
if (entry === undefined)
|
|
193
|
+
return false;
|
|
194
|
+
const index = entry.members.indexOf(id);
|
|
195
|
+
if (index < 0)
|
|
196
|
+
return false;
|
|
197
|
+
entry.members.splice(index, 1);
|
|
198
|
+
this.persist();
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Drop one id from every team — the leave and archive paths: a session
|
|
203
|
+
* leaving the network keeps no canvas membership anywhere.
|
|
204
|
+
* @param id - the departing session id.
|
|
205
|
+
*/
|
|
206
|
+
dropMember(id) {
|
|
207
|
+
let dirty = false;
|
|
208
|
+
for (const entry of this.teams) {
|
|
209
|
+
const index = entry.members.indexOf(id);
|
|
210
|
+
if (index >= 0) {
|
|
211
|
+
entry.members.splice(index, 1);
|
|
212
|
+
dirty = true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (dirty)
|
|
216
|
+
this.persist();
|
|
217
|
+
}
|
|
218
|
+
}
|
package/lib/card.js
CHANGED
|
@@ -104,6 +104,28 @@ function sessionTeams(wire) {
|
|
|
104
104
|
}
|
|
105
105
|
return entries;
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* The unsigned `teamMemberships` listing passes through only as an array of
|
|
109
|
+
* well-formed node→team entries; any other shape drops the field instead of
|
|
110
|
+
* rejecting the card — same posture as `sessionTeams`, because cards from
|
|
111
|
+
* nodes that declared no roster teams (or pre-roster versions) carry none.
|
|
112
|
+
* @param wire - the shape-checked card candidate.
|
|
113
|
+
* @returns the membership entries, or `undefined` when absent or malformed.
|
|
114
|
+
*/
|
|
115
|
+
function teamMemberships(wire) {
|
|
116
|
+
if (!Array.isArray(wire.teamMemberships))
|
|
117
|
+
return undefined;
|
|
118
|
+
const entries = [];
|
|
119
|
+
for (const entry of wire.teamMemberships) {
|
|
120
|
+
const membership = entry;
|
|
121
|
+
if (typeof membership?.node !== 'string' || membership.node === '')
|
|
122
|
+
continue;
|
|
123
|
+
if (typeof membership.team !== 'string' || membership.team === '')
|
|
124
|
+
continue;
|
|
125
|
+
entries.push({ node: membership.node, team: membership.team });
|
|
126
|
+
}
|
|
127
|
+
return entries;
|
|
128
|
+
}
|
|
107
129
|
/**
|
|
108
130
|
* Verify a parsed card: shape, signature, and expiry, in that order.
|
|
109
131
|
* @param candidate - the JSON value fetched from a peer's agent-card URL.
|
|
@@ -144,6 +166,7 @@ export function verifyCard(candidate, now) {
|
|
|
144
166
|
}
|
|
145
167
|
const peers = referrals(wire);
|
|
146
168
|
const teams = sessionTeams(wire);
|
|
169
|
+
const memberships = teamMemberships(wire);
|
|
147
170
|
return {
|
|
148
171
|
ok: true,
|
|
149
172
|
card: {
|
|
@@ -151,7 +174,9 @@ export function verifyCard(candidate, now) {
|
|
|
151
174
|
expiresAt: wire.expiresAt,
|
|
152
175
|
...(peers !== undefined ? { peers } : {}),
|
|
153
176
|
...(teams !== undefined && teams.length > 0 ? { sessionTeams: teams } : {}),
|
|
177
|
+
...(memberships !== undefined && memberships.length > 0 ? { teamMemberships: memberships } : {}),
|
|
154
178
|
...(typeof wire.lanIp === 'string' && wire.lanIp !== '' ? { lanIp: wire.lanIp } : {}),
|
|
179
|
+
...(typeof wire.version === 'string' && wire.version !== '' ? { version: wire.version } : {}),
|
|
155
180
|
publicKey: wire.publicKey,
|
|
156
181
|
signature: wire.signature,
|
|
157
182
|
},
|