@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.
Files changed (86) hide show
  1. package/README.md +243 -121
  2. package/README_zh.md +144 -84
  3. package/assets/nexusDist/assets/index-034h1cNc.js +3 -0
  4. package/assets/nexusDist/assets/index-PG9EieGX.css +1 -0
  5. package/assets/nexusDist/index.html +26 -0
  6. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -0
  7. package/assets/stageDist/assets/index-C-28k_o5.js +974 -0
  8. package/assets/stageDist/assets/interiors-CQGBjffT.png +0 -0
  9. package/assets/stageDist/assets/office-tileset-CHQT-uYL.png +0 -0
  10. package/assets/stageDist/index.html +13 -0
  11. package/lib/a2a-client.js +190 -13
  12. package/lib/canvas-store.js +218 -0
  13. package/lib/card.js +25 -0
  14. package/lib/client/A2aControl.js +45 -137
  15. package/lib/client/locales.js +16 -40
  16. package/lib/client.js +164 -467
  17. package/lib/client.js.map +1 -1
  18. package/lib/group-store.js +6 -0
  19. package/lib/idempotency-store.js +291 -0
  20. package/lib/index.js +4217 -259
  21. package/lib/joined-store.js +11 -0
  22. package/lib/layout-store.js +155 -0
  23. package/lib/peer-store.js +98 -13
  24. package/lib/receipt-ladder.js +35 -0
  25. package/lib/receipt.js +102 -0
  26. package/lib/self-suppress.js +62 -0
  27. package/lib/session-transcript.js +150 -0
  28. package/lib/skill/SKILL.md +77 -0
  29. package/lib/skill/references/discovery.md +38 -0
  30. package/lib/skill/references/membership.md +50 -0
  31. package/lib/skill/references/pitfalls.md +42 -0
  32. package/lib/skill/references/routing.md +43 -0
  33. package/lib/skill-registration.js +52 -0
  34. package/lib/stage-mount.js +32 -0
  35. package/lib/task-ledger.js +540 -0
  36. package/lib/team-store.js +122 -0
  37. package/lib/teams-bridge.js +18 -0
  38. package/lib/transport-caps.js +31 -0
  39. package/lib/types/a2a-client.d.ts +72 -1
  40. package/lib/types/canvas-store.d.ts +102 -0
  41. package/lib/types/client/A2aControl.d.ts +18 -28
  42. package/lib/types/client/locales.d.ts +1 -1
  43. package/lib/types/group-store.d.ts +6 -0
  44. package/lib/types/idempotency-store.d.ts +164 -0
  45. package/lib/types/index.d.ts +210 -6
  46. package/lib/types/joined-store.d.ts +9 -0
  47. package/lib/types/layout-store.d.ts +63 -0
  48. package/lib/types/peer-store.d.ts +45 -2
  49. package/lib/types/receipt-ladder.d.ts +25 -0
  50. package/lib/types/receipt.d.ts +51 -0
  51. package/lib/types/self-suppress.d.ts +41 -0
  52. package/lib/types/session-transcript.d.ts +37 -0
  53. package/lib/types/skill-registration.d.ts +3 -0
  54. package/lib/types/stage-mount.d.ts +29 -0
  55. package/lib/types/task-ledger.d.ts +308 -0
  56. package/lib/types/team-store.d.ts +55 -0
  57. package/lib/types/teams-bridge.d.ts +112 -0
  58. package/lib/types/transport-caps.d.ts +29 -0
  59. package/lib/types/types.d.ts +43 -0
  60. package/nexus-stage/package-lock.json +2262 -0
  61. package/nexus-stage/package.json +21 -0
  62. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
  63. package/nexus-stage/scripts/mockhost.py +253 -0
  64. package/nexus-stage/src/canvas-ops.ts +175 -0
  65. package/nexus-stage/src/canvas-wire.ts +156 -0
  66. package/nexus-stage/src/edges.ts +72 -0
  67. package/nexus-stage/src/fault.ts +56 -0
  68. package/nexus-stage/src/federation.ts +234 -0
  69. package/nexus-stage/src/layout-doc.ts +143 -0
  70. package/nexus-stage/src/layout-wire.ts +158 -0
  71. package/nexus-stage/src/main.ts +361 -0
  72. package/nexus-stage/src/planning-menus.ts +352 -0
  73. package/nexus-stage/src/planning-nodelist.ts +294 -0
  74. package/nexus-stage/src/planning-types.ts +111 -0
  75. package/nexus-stage/src/planning-view.ts +1313 -0
  76. package/nexus-stage/src/planning.css +280 -0
  77. package/nexus-stage/src/seat.ts +35 -0
  78. package/nexus-stage/src/stage-name.ts +13 -0
  79. package/nexus-stage/src/tokens.ts +32 -0
  80. package/nexus-stage/src/viewport.ts +83 -0
  81. package/nexus-stage/src/workbench.css +267 -0
  82. package/nexus-stage/src/workbench.ts +1530 -0
  83. package/nexus-stage/src/world.ts +509 -0
  84. package/nexus-stage/tsconfig.json +25 -0
  85. package/nexus-stage/vite.config.ts +16 -0
  86. package/package.json +83 -22
@@ -0,0 +1,540 @@
1
+ /**
2
+ * Persisted async-task ledger (`<dsh-home>/a2a/tasks.json`). The receipt
3
+ * contract makes the target answer a fire-and-forget route with a message
4
+ * starting `[A2A receipt] task <task_id> <outcome summary>`, but the caller
5
+ * had no book to reconcile against — pending tasks were unqueryable and a
6
+ * restart forgot them all. The ledger keeps one record per routed task that
7
+ * is still owed a reply, correlates the receipt by its task id, and persists
8
+ * so a node restart does not orphan the reconciliation.
9
+ *
10
+ * Three-tier book (v0.5.29): the owed book holds only unsettled rows —
11
+ * `pending`, or `dead` once a row sits past {@link TASK_STALE_TTL_MS} (swept
12
+ * lazily on every entry point, never timer-driven — this host restarts too
13
+ * often for in-memory timers). Dead rows exit the owed view and disarm the
14
+ * async nudge via {@link TaskLedger.isPending}, yet still settle on a late
15
+ * receipt. Every settled row leaves the owed book for the bounded archive,
16
+ * so resolution history stops being silently evicted by the {@link TASK_CAP}
17
+ * slice (the evidence-loss defect behind the "the ledger keeps growing"
18
+ * illusion: settlements vanished while debts piled up).
19
+ * @module @nelsonlongxiang/dsh-open-a2a-net/task-ledger
20
+ */
21
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
+ import { dirname } from 'node:path';
23
+ import { parseReceipt, RECEIPT_OUTCOMES } from "./receipt.js";
24
+ /**
25
+ * Caller-ended summaries (abandon / cancel) share one isolation law: late
26
+ * receipts never rewrite the verdict — they only stamp lateReceiptAt, and
27
+ * the projected outcome collapses to 'abandoned'.
28
+ */
29
+ const CALLER_ENDED_PREFIX = /^caller-(abandoned|cancelled)/;
30
+ /** Hard cap on the owed book (pending + dead-lettered rows, most recent first). */
31
+ export const TASK_CAP = 64;
32
+ /** Hard cap on the settled archive (most recent kept). */
33
+ export const ARCHIVE_CAP = 64;
34
+ /** Longest receipt summary kept on a record (multi-line outcomes collapse). */
35
+ export const SUMMARY_CAP = 200;
36
+ /**
37
+ * Longest dispatch brief kept per row. A ledger row without its payload is
38
+ * unreadable to supervision — "direct-8f3a1c2b → dsh/ec4ce7f8, 3h old" says
39
+ * nothing about whether to nudge, abandon, or read the receipt. The brief is
40
+ * the first non-empty line of the message, capped; the full body never enters
41
+ * the ledger (it can be arbitrarily large and may quote another session's log).
42
+ */
43
+ export const BRIEF_CAP = 120;
44
+ /** Reduce a dispatch body to one readable line: first non-empty line, collapsed, capped. */
45
+ export function briefOf(message) {
46
+ if (message === undefined)
47
+ return undefined;
48
+ for (const raw of message.split('\n')) {
49
+ const line = raw.trim().replace(/\s+/g, ' ');
50
+ if (line !== '')
51
+ return line.length <= BRIEF_CAP ? line : `${line.slice(0, BRIEF_CAP - 1)}…`;
52
+ }
53
+ return undefined;
54
+ }
55
+ /**
56
+ * Age at which an unsettled task auto-dead-letters. A dead row exits the
57
+ * owed view, disarms its async-nudge retry (a cold session cannot consume a
58
+ * re-steer anyway), and still accepts a late receipt from a revived target.
59
+ * Injectable per instance so tests can use short windows; sweeping stays
60
+ * lazy — every read/write entry point sweeps first.
61
+ */
62
+ export const TASK_STALE_TTL_MS = 24 * 60 * 60_000;
63
+ /**
64
+ * Bounded outbound-task ledger with whole-file persistence. Pure of any
65
+ * routing knowledge: callers {@link track} a task when a route leaves it
66
+ * owed a receipt and feed every inbound/relayed message through
67
+ * {@link resolveFromMessage} for correlation.
68
+ */
69
+ export class TaskLedger {
70
+ path;
71
+ /** Owed-book rows, most recently tracked first; settled rows leave here. */
72
+ tasks = [];
73
+ /** Settled rows, most recently resolved first; bounded by the archive cap. */
74
+ archived = [];
75
+ staleTtlMs;
76
+ archiveCap;
77
+ /** Injectable clock (option `now`). */
78
+ now;
79
+ /**
80
+ * @param path - persistence file (`<dsh-home>/a2a/tasks.json`); empty = no persistence.
81
+ * @param options - TTL/capacity overrides; defaults come from the exported constants.
82
+ */
83
+ constructor(path, options) {
84
+ this.path = path;
85
+ this.staleTtlMs = options?.staleTtlMs ?? TASK_STALE_TTL_MS;
86
+ this.archiveCap = options?.archiveCap ?? ARCHIVE_CAP;
87
+ this.now = options?.now ?? (() => Date.now());
88
+ this.restore();
89
+ }
90
+ /**
91
+ * Every unsettled task (pending + dead-lettered), most recently tracked first.
92
+ * Sweeping runs first, so an overdue pending row reports as dead here.
93
+ * @returns the owed-book records.
94
+ */
95
+ list() {
96
+ this.sweep(this.now());
97
+ return [...this.tasks];
98
+ }
99
+ /**
100
+ * The settled archive, most recently resolved first. Rows land here the
101
+ * moment their receipt correlates and stay until the archive cap evicts
102
+ * the oldest — receipts keep correlating against them, so a repeat or a
103
+ * delayed duplicate refreshes instead of resolving nothing.
104
+ * @returns the archived records.
105
+ */
106
+ archive() {
107
+ return [...this.archived];
108
+ }
109
+ /**
110
+ * Remember one outbound task owed a receipt, most-recent first, bounded by
111
+ * {@link TASK_CAP} over the owed book only (settled rows live in the
112
+ * archive, never silently evicted). A task id is caller-born and
113
+ * single-use: a known id in either tier keeps its first record verbatim.
114
+ * @param taskId - the correlation key the receipt echoes.
115
+ * @param team - the team the route addressed.
116
+ * @param peer - the candidate that accepted the delivery ('local' or URL).
117
+ * @param contextId - the delivery's conversation id, kept for follow-up routes (empty omits it).
118
+ */
119
+ /**
120
+ * Fix ② (ghost-debt stop): register one delivered async dispatch whose
121
+ * callback address could not be resolved — the row stays VISIBLE (the
122
+ * ledger is honest) but carries receiptExpected:false, so neither the
123
+ * owed book nor the settleable bucket ever counts it. Reconciliation is
124
+ * via context_id follow-ups (the dispatch reply already says so).
125
+ * Idempotent by task id like {@link track}.
126
+ */
127
+ trackReceiptless(taskId, team, peer, contextId, brief) {
128
+ if (taskId === '')
129
+ return;
130
+ this.sweep(this.now());
131
+ if (this.tasks.some(entry => entry.taskId === taskId))
132
+ return;
133
+ if (this.archived.some(entry => entry.taskId === taskId))
134
+ return;
135
+ this.tasks = [{
136
+ taskId,
137
+ team,
138
+ peer,
139
+ startedAt: this.now(),
140
+ direction: 'outbound',
141
+ receiptExpected: false,
142
+ ...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
143
+ ...(brief !== undefined && brief !== '' ? { brief } : {}),
144
+ status: 'pending',
145
+ }, ...this.tasks].slice(0, TASK_CAP);
146
+ this.persist();
147
+ }
148
+ track(taskId, team, peer, contextId, brief) {
149
+ if (taskId === '')
150
+ return;
151
+ this.sweep(this.now());
152
+ if (this.tasks.some(entry => entry.taskId === taskId))
153
+ return;
154
+ if (this.archived.some(entry => entry.taskId === taskId))
155
+ return;
156
+ this.tasks = [{
157
+ taskId,
158
+ team,
159
+ peer,
160
+ startedAt: this.now(),
161
+ direction: 'outbound',
162
+ ...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
163
+ ...(brief !== undefined && brief !== '' ? { brief } : {}),
164
+ status: 'pending',
165
+ }, ...this.tasks].slice(0, TASK_CAP);
166
+ this.persist();
167
+ }
168
+ /**
169
+ * F2' (receipt-settlement): remember one INBOUND delivery owed a receipt —
170
+ * this node is the settlement debtor here: the caller waits for a receipt
171
+ * only this target can produce. Before this row type existed, inbound
172
+ * deliveries were born ledger-less, so the async nudge's isPending gate
173
+ * no-op'd for exactly the deliveries that stall (08-30 evidence: zero
174
+ * ledger records for latched activations). Idempotent by task id like
175
+ * {@link track}.
176
+ * @param taskId - the correlation key the caller's task was born with.
177
+ * @param team - the team the delivery addressed (this node's).
178
+ * @param from - the caller's receipt target (label or callback address).
179
+ * @param contextId - the delivery's conversation id, kept for follow-up routes (empty omits it).
180
+ */
181
+ trackInbound(taskId, team, from, contextId, receiptExpected, brief) {
182
+ if (taskId === '')
183
+ return;
184
+ this.sweep(this.now());
185
+ if (this.tasks.some(entry => entry.taskId === taskId))
186
+ return;
187
+ if (this.archived.some(entry => entry.taskId === taskId))
188
+ return;
189
+ this.tasks = [{
190
+ taskId,
191
+ team,
192
+ peer: `local:${from}`,
193
+ startedAt: this.now(),
194
+ direction: 'inbound',
195
+ ...(receiptExpected === false ? { receiptExpected: false } : {}),
196
+ ...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
197
+ ...(brief !== undefined && brief !== '' ? { brief } : {}),
198
+ status: 'pending',
199
+ }, ...this.tasks].slice(0, TASK_CAP);
200
+ this.persist();
201
+ }
202
+ /**
203
+ * v0.5.23 (async-stall): whether a tracked task still owes its receipt.
204
+ * The async nudge consults this — a resolved or dead-lettered task
205
+ * disarms its retry (re-steering a session that could not start a turn
206
+ * past its own stale window is noise, not recovery).
207
+ * @param taskId - the correlation key the receipt echoes.
208
+ * @returns true when the task is tracked pending inside its TTL.
209
+ */
210
+ isPending(taskId) {
211
+ const record = this.tasks.find(entry => entry.taskId === taskId);
212
+ if (record === undefined || record.status !== 'pending')
213
+ return false;
214
+ return this.now() - record.startedAt <= this.staleTtlMs;
215
+ }
216
+ /**
217
+ * Caller-side give-up (work-order P1a): clears one owed row into the
218
+ * archive under a `caller-abandoned` outcome so waiters stop occupying the
219
+ * remote budget and the three-tier book stays honest about who ended it.
220
+ * NOT cooperative cancellation — the remote may still be working. A late
221
+ * receipt against an abandoned row lands as orphan metadata
222
+ * ({@link ArchivedRecord.lateReceiptAt}) without rewriting the decision.
223
+ * Idempotent and total per the consumer contract: repeat calls and unknown
224
+ * ids return stable structured outcomes, never throw.
225
+ * @param taskId - the correlation key to abandon.
226
+ * @param reason - optional short cause, folded into the archived summary.
227
+ * @returns `{outcome:'cleared'}` when a pending/dead row was settled now,
228
+ * `'already-terminal'` when the archive already held the task's end,
229
+ * `'unknown'` when neither tier ever saw the id.
230
+ */
231
+ abandon(taskId, reason) {
232
+ if (taskId === '')
233
+ return { outcome: 'unknown', taskId };
234
+ this.sweep(this.now());
235
+ const record = this.tasks.find(entry => entry.taskId === taskId);
236
+ if (record !== undefined) {
237
+ this.tasks = this.tasks.filter(entry => entry.taskId !== taskId);
238
+ const resolvedAt = this.now();
239
+ const cause = reason === undefined || reason === '' ? '' : `: ${reason.slice(0, SUMMARY_CAP - 'caller-abandoned:'.length)}`;
240
+ this.archiveSettled({
241
+ taskId: record.taskId,
242
+ team: record.team,
243
+ peer: record.peer,
244
+ startedAt: record.startedAt,
245
+ ...(record.contextId !== undefined ? { contextId: record.contextId } : {}),
246
+ ...(record.brief !== undefined ? { brief: record.brief } : {}),
247
+ resolvedAt,
248
+ summary: `caller-abandoned${cause}`,
249
+ outcome: 'abandoned',
250
+ });
251
+ this.persist();
252
+ return { outcome: 'cleared', taskId, archivedAt: resolvedAt };
253
+ }
254
+ const settled = this.archived.find(entry => entry.taskId === taskId);
255
+ if (settled === undefined)
256
+ return { outcome: 'unknown', taskId };
257
+ return { outcome: 'already-terminal', taskId, archivedAt: settled.resolvedAt };
258
+ }
259
+ /**
260
+ * Cooperative cancellation (work-order P1b): ends one owed row like
261
+ * {@link abandon} but marks it `caller-cancelled` so downstream tooling
262
+ * tells "stopped waiting" from "ordered a stop". Implemented standalone
263
+ * (not via a shared endTask core) deliberately: abandon() is already
264
+ * reviewed semantics on this stack — touching it would ripple re-review;
265
+ * the ~20 duplicated lines buy a frozen reviewed surface.
266
+ * Cross-node targets degrade honestly: no reachable lane ⇒ no notify —
267
+ * the route layer reads team/contextId from here for its best-effort
268
+ * steer, which is the documented boundary until origin-auth lands.
269
+ */
270
+ cancel(taskId, reason) {
271
+ if (taskId === '')
272
+ return { outcome: 'unknown', taskId };
273
+ this.sweep(this.now());
274
+ const record = this.tasks.find(entry => entry.taskId === taskId);
275
+ if (record !== undefined) {
276
+ this.tasks = this.tasks.filter(entry => entry.taskId !== taskId);
277
+ const resolvedAt = this.now();
278
+ const cause = reason === undefined || reason === '' ? '' : `: ${reason.slice(0, SUMMARY_CAP - 'caller-cancelled:'.length)}`;
279
+ this.archiveSettled({
280
+ taskId: record.taskId,
281
+ team: record.team,
282
+ peer: record.peer,
283
+ startedAt: record.startedAt,
284
+ ...(record.contextId !== undefined ? { contextId: record.contextId } : {}),
285
+ ...(record.brief !== undefined ? { brief: record.brief } : {}),
286
+ resolvedAt,
287
+ summary: `caller-cancelled${cause}`,
288
+ outcome: 'abandoned',
289
+ });
290
+ this.persist();
291
+ return {
292
+ outcome: 'cleared',
293
+ taskId,
294
+ archivedAt: resolvedAt,
295
+ team: record.team,
296
+ ...(record.contextId !== undefined ? { contextId: record.contextId } : {}),
297
+ };
298
+ }
299
+ const settled = this.archived.find(entry => entry.taskId === taskId);
300
+ if (settled === undefined)
301
+ return { outcome: 'unknown', taskId };
302
+ return { outcome: 'already-terminal', taskId, archivedAt: settled.resolvedAt };
303
+ }
304
+ /**
305
+ * Correlate one message against the ledger: a receipt (message starting
306
+ * `[A2A receipt] task <id> …`) settles its task into the archive, keeping
307
+ * the outcome summary; a repeat or late receipt refreshes that archived
308
+ * record with the latest outcome. A dead-lettered row revives through this
309
+ * same path when a revived target finally answers.
310
+ * @param message - the inbound or relayed message text.
311
+ * @returns what the receipt settled, or `undefined` when the message
312
+ * correlated nothing.
313
+ */
314
+ resolveFromMessage(message) {
315
+ const parsed = parseReceipt(message);
316
+ if (parsed === null)
317
+ return undefined;
318
+ const taskId = parsed.taskId;
319
+ const summary = parsed.summary.trim().slice(0, SUMMARY_CAP); // Controlled vocabulary only: a foreign outcome string never lands here.
320
+ const envelopeOutcome = parsed.envelope?.outcome !== undefined ? parsed.envelope.outcome : undefined;
321
+ const elapsedMs = parsed.envelope?.elapsedMs;
322
+ this.sweep(this.now());
323
+ const record = this.tasks.find(entry => entry.taskId === taskId);
324
+ if (record !== undefined) {
325
+ this.tasks = this.tasks.filter(entry => entry.taskId !== taskId);
326
+ this.archiveSettled({
327
+ taskId: record.taskId,
328
+ team: record.team,
329
+ peer: record.peer,
330
+ startedAt: record.startedAt,
331
+ ...(record.contextId !== undefined ? { contextId: record.contextId } : {}),
332
+ ...(record.brief !== undefined ? { brief: record.brief } : {}),
333
+ resolvedAt: this.now(),
334
+ ...(summary !== '' ? { summary } : {}),
335
+ ...(envelopeOutcome !== undefined ? { outcome: envelopeOutcome } : {}),
336
+ ...(elapsedMs !== undefined ? { elapsedMs } : {}),
337
+ });
338
+ this.persist();
339
+ return {
340
+ taskId,
341
+ team: record.team,
342
+ peer: record.peer,
343
+ // A dead row still lives in the tasks array (the sweep only flips
344
+ // status): its receipt is a revival — the late marker's first form.
345
+ ...(record.status === 'dead' ? { late: true } : {}),
346
+ ...(envelopeOutcome !== undefined ? { outcome: envelopeOutcome } : {}),
347
+ ...(summary !== '' ? { summary } : {}),
348
+ };
349
+ }
350
+ const settled = this.archived.find(entry => entry.taskId === taskId);
351
+ if (settled === undefined)
352
+ return undefined;
353
+ // Orphan isolation: a receipt for an explicitly abandoned row must not
354
+ // rewrite the abandonment outcome — the caller stopped waiting by choice.
355
+ // Record that the target did answer later; keep the decision verbatim and
356
+ // pin the projected verdict to 'abandoned'.
357
+ const callerEnded = CALLER_ENDED_PREFIX.test(settled.summary ?? '');
358
+ this.archived = [
359
+ {
360
+ ...settled,
361
+ ...(callerEnded
362
+ ? { lateReceiptAt: this.now(), outcome: 'abandoned' }
363
+ : {
364
+ resolvedAt: this.now(),
365
+ ...(summary !== '' ? { summary } : {}),
366
+ ...(envelopeOutcome !== undefined ? { outcome: envelopeOutcome } : {}),
367
+ }),
368
+ },
369
+ ...this.archived.filter(entry => entry.taskId !== taskId),
370
+ ];
371
+ this.persist();
372
+ return {
373
+ taskId,
374
+ team: settled.team,
375
+ peer: settled.peer,
376
+ ...(envelopeOutcome !== undefined ? { outcome: envelopeOutcome } : {}),
377
+ ...(summary !== '' ? { summary } : {}),
378
+ // The late marker's second form: the caller had already moved on
379
+ // (abandonment recorded verbatim). A HEALTHY row's duplicate refresh
380
+ // is neither dead-lettered nor abandoned — it carries no marker.
381
+ ...(callerEnded ? { late: true } : {}),
382
+ };
383
+ }
384
+ /**
385
+ * F5 (settlement integrity): settle pending rows by CONTENT ECHO — an
386
+ * inbound message that references `task <id>` of a pending row counts as
387
+ * the peer demonstrably responding to that task, even when the formal
388
+ * `[A2A receipt]` envelope never rode the reply (the 08-30 shape: content
389
+ * answered, formality lost). The receipt envelope keeps precedence: this
390
+ * runs only when {@link resolveFromMessage} correlated nothing. Echo-
391
+ * settled rows archive with a marker summary so the audit trail shows
392
+ * they were echoes, not envelopes.
393
+ * @param message - the inbound message text.
394
+ * @returns the task ids this message settled (usually none or one).
395
+ */
396
+ resolveEcho(message) {
397
+ const settled = [];
398
+ this.sweep(this.now());
399
+ for (const record of [...this.tasks]) {
400
+ if (record.status !== 'pending')
401
+ continue;
402
+ if (!message.includes(`task ${record.taskId}`))
403
+ continue;
404
+ this.tasks = this.tasks.filter(entry => entry.taskId !== record.taskId);
405
+ this.archiveSettled({
406
+ taskId: record.taskId,
407
+ team: record.team,
408
+ peer: record.peer,
409
+ startedAt: record.startedAt,
410
+ ...(record.contextId !== undefined ? { contextId: record.contextId } : {}),
411
+ resolvedAt: this.now(),
412
+ summary: '(settled by content echo)',
413
+ });
414
+ settled.push(record.taskId);
415
+ }
416
+ if (settled.length > 0)
417
+ this.persist();
418
+ return settled;
419
+ }
420
+ /** Move one settled record to the archive head, trimming at the cap. */
421
+ archiveSettled(record) {
422
+ this.archived = [record, ...this.archived].slice(0, this.archiveCap);
423
+ }
424
+ /** Lazy dead-letter sweep: overdue pending rows flip status, at most once. */
425
+ sweep(now) {
426
+ let changed = false;
427
+ for (const entry of this.tasks) {
428
+ if (entry.status === 'pending' && now - entry.startedAt > this.staleTtlMs) {
429
+ entry.status = 'dead';
430
+ entry.deadAt = now;
431
+ // F5 layer-3: stamp WHY the row aged — a fire-and-forget row
432
+ // (receiptExpected:false) expiring is expected bookkeeping; a
433
+ // receipt-owed row expiring means the settlement chain failed for
434
+ // that delivery and deserves supervision attention.
435
+ entry.reason = entry.receiptExpected === false ? 'fire-forget' : 'unconsumed';
436
+ changed = true;
437
+ }
438
+ }
439
+ if (changed)
440
+ this.persist();
441
+ }
442
+ /** Load a persisted snapshot on construction, if present. */
443
+ restore() {
444
+ if (this.path === '' || !existsSync(this.path))
445
+ return;
446
+ try {
447
+ const snapshot = JSON.parse(readFileSync(this.path, 'utf8'));
448
+ const entries = snapshot?.tasks;
449
+ const restored = [];
450
+ if (Array.isArray(entries)) {
451
+ for (const raw of entries) {
452
+ // Unparsed on purpose: the legacy shape carries a 'resolved' status
453
+ // and inline outcome fields that TaskRecord no longer declares.
454
+ const entry = raw;
455
+ if (typeof entry?.taskId !== 'string' || entry.taskId === '')
456
+ continue;
457
+ // Legacy two-state files carried resolved rows inline; they migrate
458
+ // into the archive so pre-upgrade outcomes survive the upgrade.
459
+ if (entry.status === 'resolved') {
460
+ this.archived.push({
461
+ taskId: entry.taskId,
462
+ team: typeof entry.team === 'string' ? entry.team : '',
463
+ peer: typeof entry.peer === 'string' ? entry.peer : '',
464
+ startedAt: typeof entry.startedAt === 'number' && Number.isFinite(entry.startedAt) ? entry.startedAt : this.now(),
465
+ ...(typeof entry.contextId === 'string' && entry.contextId !== '' ? { contextId: entry.contextId } : {}),
466
+ ...(typeof entry.brief === 'string' && entry.brief !== '' ? { brief: entry.brief } : {}),
467
+ resolvedAt: typeof entry.resolvedAt === 'number' && Number.isFinite(entry.resolvedAt) ? entry.resolvedAt : this.now(),
468
+ ...(typeof entry.summary === 'string' && entry.summary !== '' ? { summary: entry.summary } : {}),
469
+ ...(typeof entry.outcome === 'string' && RECEIPT_OUTCOMES.includes(entry.outcome) ? { outcome: entry.outcome } : {}),
470
+ ...(typeof entry.lateReceiptAt === 'number' && Number.isFinite(entry.lateReceiptAt) ? { lateReceiptAt: entry.lateReceiptAt } : {}),
471
+ });
472
+ continue;
473
+ }
474
+ restored.push({
475
+ taskId: entry.taskId,
476
+ team: typeof entry.team === 'string' ? entry.team : '',
477
+ peer: typeof entry.peer === 'string' ? entry.peer : '',
478
+ startedAt: typeof entry.startedAt === 'number' && Number.isFinite(entry.startedAt) ? entry.startedAt : this.now(),
479
+ ...(typeof entry.contextId === 'string' && entry.contextId !== '' ? { contextId: entry.contextId } : {}),
480
+ ...(typeof entry.brief === 'string' && entry.brief !== '' ? { brief: entry.brief } : {}),
481
+ ...(entry.direction === 'inbound' ? { direction: 'inbound' } : {}),
482
+ ...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
483
+ status: entry.status === 'dead' ? 'dead' : 'pending',
484
+ ...(entry.status === 'dead'
485
+ ? {
486
+ deadAt: typeof entry.deadAt === 'number' && Number.isFinite(entry.deadAt) ? entry.deadAt : this.now(),
487
+ ...(entry.reason === 'fire-forget' || entry.reason === 'unconsumed' ? { reason: entry.reason } : {}),
488
+ }
489
+ : {}),
490
+ });
491
+ }
492
+ }
493
+ const legacyArchive = snapshot?.archived;
494
+ if (Array.isArray(legacyArchive)) {
495
+ for (const raw of legacyArchive) {
496
+ const entry = raw;
497
+ if (typeof entry?.taskId !== 'string' || entry.taskId === '')
498
+ continue;
499
+ if (this.archived.some(existing => existing.taskId === entry.taskId))
500
+ continue;
501
+ this.archived.push({
502
+ taskId: entry.taskId,
503
+ team: typeof entry.team === 'string' ? entry.team : '',
504
+ peer: typeof entry.peer === 'string' ? entry.peer : '',
505
+ startedAt: typeof entry.startedAt === 'number' && Number.isFinite(entry.startedAt) ? entry.startedAt : this.now(),
506
+ ...(typeof entry.contextId === 'string' && entry.contextId !== '' ? { contextId: entry.contextId } : {}),
507
+ ...(typeof entry.brief === 'string' && entry.brief !== '' ? { brief: entry.brief } : {}),
508
+ resolvedAt: typeof entry.resolvedAt === 'number' && Number.isFinite(entry.resolvedAt) ? entry.resolvedAt : this.now(),
509
+ ...(typeof entry.summary === 'string' && entry.summary !== '' ? { summary: entry.summary } : {}),
510
+ ...(typeof entry.outcome === 'string' && RECEIPT_OUTCOMES.includes(entry.outcome) ? { outcome: entry.outcome } : {}),
511
+ ...(typeof entry.lateReceiptAt === 'number' && Number.isFinite(entry.lateReceiptAt) ? { lateReceiptAt: entry.lateReceiptAt } : {}),
512
+ });
513
+ }
514
+ }
515
+ this.tasks = restored.slice(0, TASK_CAP);
516
+ this.archived = this.archived.slice(0, this.archiveCap);
517
+ }
518
+ catch {
519
+ // A corrupt ledger is not fatal: routing never depends on it; the
520
+ // reconciliation simply starts fresh.
521
+ }
522
+ }
523
+ /** Persist the ledger (no-op when no path was configured). */
524
+ persist() {
525
+ if (this.path === '')
526
+ return;
527
+ try {
528
+ mkdirSync(dirname(this.path), { recursive: true });
529
+ const snapshot = {
530
+ tasks: this.tasks.map(task => ({ ...task })),
531
+ ...(this.archived.length > 0 ? { archived: this.archived.map(entry => ({ ...entry })) } : {}),
532
+ };
533
+ writeFileSync(this.path, JSON.stringify(snapshot), { mode: 0o600 });
534
+ }
535
+ catch {
536
+ // An unwritable home must not break routing; the ledger degrades to
537
+ // memory-only.
538
+ }
539
+ }
540
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Persisted team-membership declarations: which routable teams each joined
3
+ * session node declares itself a member of (`a2a/teams.json`). Membership
4
+ * is the node's own state — the inverse of canvas teams, where a team
5
+ * declares its members — and is published serve-fresh on the host card so
6
+ * a team's roster rebuilds as the union of member declarations (desired
7
+ * state, no central directory; the S2 half of the team-roster iteration).
8
+ * Joins are allowlist-gated at the tool layer; the store is pure storage.
9
+ * Plain JSON, one writer (the a2a_team_join / a2a_team_leave tools), read
10
+ * fresh by the state route.
11
+ * @module @nelsonlongxiang/dsh-open-a2a-net/team-store
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { dirname } from 'node:path';
15
+ /** Upper bounds: a curation surface, not a database. */
16
+ export const TEAM_NODE_CAP = 64;
17
+ export const TEAMS_PER_NODE_CAP = 16;
18
+ /** Longest accepted team name (full routable form, `zone/<id8>` etc.). */
19
+ export const TEAM_NAME_MAX = 60;
20
+ function validTeam(raw) {
21
+ const clean = raw.trim();
22
+ if (clean === '' || clean.length > TEAM_NAME_MAX)
23
+ return undefined;
24
+ return clean;
25
+ }
26
+ /**
27
+ * Bounded membership store with last-write-wins persistence. Pure of any
28
+ * policy: callers gate joins and resolve rosters.
29
+ */
30
+ export class TeamMembershipStore {
31
+ /** Entries in first-declaration order; teams in join order. */
32
+ entries = [];
33
+ path;
34
+ /**
35
+ * @param path - the snapshot file; an empty path keeps the store in memory.
36
+ */
37
+ constructor(path) {
38
+ this.path = path;
39
+ this.restore();
40
+ }
41
+ /** Load a persisted snapshot, if present and well-formed. */
42
+ restore() {
43
+ if (this.path === '' || !existsSync(this.path))
44
+ return;
45
+ try {
46
+ const snapshot = JSON.parse(readFileSync(this.path, 'utf8'));
47
+ const raw = Array.isArray(snapshot?.memberships)
48
+ ? snapshot.memberships
49
+ : [];
50
+ for (const entry of raw) {
51
+ if (typeof entry.session !== 'string' || entry.session === '')
52
+ continue;
53
+ if (!Array.isArray(entry.teams))
54
+ continue;
55
+ const teams = entry.teams
56
+ .filter((team) => typeof team === 'string')
57
+ .map(team => validTeam(team))
58
+ .filter((team) => team !== undefined);
59
+ if (teams.length > 0)
60
+ this.entries.push({ session: entry.session, teams });
61
+ }
62
+ }
63
+ catch {
64
+ // A malformed snapshot stays unloaded; the next write replaces it.
65
+ }
66
+ }
67
+ persist() {
68
+ if (this.path === '')
69
+ return;
70
+ mkdirSync(dirname(this.path), { recursive: true });
71
+ writeFileSync(this.path, JSON.stringify({ memberships: this.entries }));
72
+ }
73
+ /** Membership entries, copies — callers cannot mutate the store. */
74
+ list() {
75
+ return this.entries.map(entry => ({ session: entry.session, teams: [...entry.teams] }));
76
+ }
77
+ /** Teams one session node declares, in join order. */
78
+ teamsOf(session) {
79
+ return [...(this.entries.find(entry => entry.session === session)?.teams ?? [])];
80
+ }
81
+ /** Local sessions declaring one team, in declaration order. */
82
+ membersOf(team) {
83
+ return this.entries.filter(entry => entry.teams.includes(team)).map(entry => entry.session);
84
+ }
85
+ /** Declare one membership. Idempotent; silently caps at the bounds. */
86
+ add(session, team) {
87
+ const clean = validTeam(team);
88
+ if (clean === undefined || session === '')
89
+ return;
90
+ let entry = this.entries.find(candidate => candidate.session === session);
91
+ if (entry === undefined) {
92
+ if (this.entries.length >= TEAM_NODE_CAP)
93
+ return;
94
+ entry = { session, teams: [] };
95
+ this.entries.push(entry);
96
+ }
97
+ if (entry.teams.includes(clean) || entry.teams.length >= TEAMS_PER_NODE_CAP)
98
+ return;
99
+ entry.teams.push(clean);
100
+ this.persist();
101
+ }
102
+ /** Retract one membership; a no-op when absent. */
103
+ remove(session, team) {
104
+ const entry = this.entries.find(candidate => candidate.session === session);
105
+ if (entry === undefined)
106
+ return;
107
+ const index = entry.teams.indexOf(team);
108
+ if (index >= 0) {
109
+ entry.teams.splice(index, 1);
110
+ if (entry.teams.length === 0)
111
+ this.entries = this.entries.filter(candidate => candidate.session !== session);
112
+ this.persist();
113
+ }
114
+ }
115
+ /** Drop every declaration of one session (a left or archived node). */
116
+ dropSession(session) {
117
+ const before = this.entries.length;
118
+ this.entries = this.entries.filter(entry => entry.session !== session);
119
+ if (this.entries.length !== before)
120
+ this.persist();
121
+ }
122
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Structural contract for the native-teams A2A transport bridge (the
3
+ * node-unification design's outbound half-bridge).
4
+ *
5
+ * Contract ownership belongs to `@nelsonlongxiang/dsh-native-teams`
6
+ * (`src/a2a-face.ts`, 0.14.0). These are this plugin's local structural
7
+ * mirrors — plugins never value-import each other, so the shapes are
8
+ * restated here and kept in lockstep by the three-party protocol docs
9
+ * (exposure-grants / t3-declaration-remote-members). Any drift is a
10
+ * protocol-doc bug first.
11
+ * @module @nelsonlongxiang/dsh-open-a2a-net/teams-bridge
12
+ */
13
+ /**
14
+ * Service key the face mounts under. Frozen by the tool face's
15
+ * `NATIVE_TEAMS_A2A_FACE_KEY` (mirrored verbatim per the single-source
16
+ * discipline; the protocol docs arbitrate any dispute).
17
+ */
18
+ export const NATIVE_TEAMS_A2A_FACE_KEY = 'nativeTeamsA2a';