@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
|
@@ -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';
|