@nelsonlongxiang/dsh-open-a2a-net 0.6.33 → 0.6.35
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 +5 -2
- package/README_zh.md +5 -2
- package/assets/nexusDist/assets/index-Du2E6DJL.js +9 -0
- package/assets/nexusDist/assets/index-z33lHtLw.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +3 -1
- package/lib/a2a-client.js +9 -2
- package/lib/auth-observer.js +1 -1
- package/lib/canvas-store.js +64 -6
- package/lib/card.js +2 -1
- package/lib/cli-home.js +44 -0
- package/lib/client/A2aControl.js +1 -1
- package/lib/client.js +65 -65
- package/lib/client.js.map +1 -1
- package/lib/direct-forward.js +61 -0
- package/lib/faces/admin.js +43 -6
- package/lib/faces/wire.js +57 -40
- package/lib/flood-guard.js +7 -1
- package/lib/index.js +117 -65
- package/lib/outbound-scope.js +25 -0
- package/lib/queue-guard.js +37 -0
- package/lib/response-fields.js +27 -0
- package/lib/skill/references/cli.md +11 -4
- package/lib/skill/references/pitfalls.md +5 -4
- package/lib/skill/references/routing.md +4 -0
- package/lib/task-auth.js +52 -3
- package/lib/task-ledger.js +4 -1
- package/lib/tools/info.js +19 -3
- package/lib/types/a2a-client.d.ts +1 -1
- package/lib/types/auth-observer.d.ts +2 -2
- package/lib/types/canvas-store.d.ts +15 -1
- package/lib/types/cli-home.d.ts +24 -0
- package/lib/types/client/A2aControl.d.ts +1 -1
- package/lib/types/direct-forward.d.ts +32 -0
- package/lib/types/faces/admin.d.ts +8 -1
- package/lib/types/faces/wire.d.ts +12 -4
- package/lib/types/flood-guard.d.ts +5 -0
- package/lib/types/index.d.ts +4 -0
- package/lib/types/outbound-scope.d.ts +21 -0
- package/lib/types/queue-guard.d.ts +32 -0
- package/lib/types/response-fields.d.ts +3 -0
- package/lib/types/runtime.d.ts +1 -1
- package/lib/types/task-auth.d.ts +17 -2
- package/lib/types/task-ledger.d.ts +6 -1
- package/lib/types/types.d.ts +8 -0
- package/nexus-stage/src/cli-home-picker.ts +42 -0
- package/nexus-stage/src/main.ts +9 -2
- package/nexus-stage/src/planning-card.ts +61 -0
- package/nexus-stage/src/planning-menus.ts +42 -7
- package/nexus-stage/src/planning-view.ts +14 -32
- package/nexus-stage/src/planning.css +17 -0
- package/nexus-stage/src/workbench.css +4 -0
- package/nexus-stage/src/workbench.ts +28 -5
- package/nexus-stage/src/world.ts +5 -1
- package/package.json +2 -1
- package/assets/nexusDist/assets/index-BACVZe0j.css +0 -1
- package/assets/nexusDist/assets/index-Bbxnpf2p.js +0 -9
package/lib/index.js
CHANGED
|
@@ -34,6 +34,9 @@ import { LayoutStore } from "./layout-store.js";
|
|
|
34
34
|
import { JoinedSessions } from "./joined-store.js";
|
|
35
35
|
import { ExposureStore } from "./exposure-store.js";
|
|
36
36
|
import { CLI_HANDLE_RE, CliDepositStore } from "./cli-deposit.js";
|
|
37
|
+
import { cliHomeChoices, decideCliHome } from "./cli-home.js";
|
|
38
|
+
import { outboundScope } from "./outbound-scope.js";
|
|
39
|
+
import { createDirectForward } from "./direct-forward.js";
|
|
37
40
|
import { TeamMembershipStore } from "./team-store.js";
|
|
38
41
|
import { PeerStore } from "./peer-store.js";
|
|
39
42
|
import { SelfReferralFilter } from "./self-suppress.js";
|
|
@@ -42,11 +45,12 @@ import { formatReceipt, parseReceipt, taskIdRefusal } from "./receipt.js";
|
|
|
42
45
|
import { runReceiptLadder } from "./receipt-ladder.js";
|
|
43
46
|
import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
|
|
44
47
|
import { createFloodGuard } from "./flood-guard.js";
|
|
48
|
+
import { createQueueGuard, DEFAULT_TARGET_QUEUE_CAP, queueFullRefusal, WIRE_ERROR_QUEUE_FULL } from "./queue-guard.js";
|
|
45
49
|
import { resolveZone } from "./zone.js";
|
|
46
50
|
import { registerA2aNetworkSkill } from "./skill-registration.js";
|
|
47
51
|
import { isRootOriginUrl, sameMountedUrl, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
|
|
48
52
|
import { AuthObserver } from "./auth-observer.js";
|
|
49
|
-
import { allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission } from "./task-auth.js";
|
|
53
|
+
import { allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization } from "./task-auth.js";
|
|
50
54
|
import s from '@deepseek-ai/schemastery';
|
|
51
55
|
import { MessageId } from '@deepseek-ai/dsh-llm';
|
|
52
56
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
@@ -83,6 +87,7 @@ export const Config = s.object({
|
|
|
83
87
|
settlementAuth: s.union(['off', 'observe', 'enforce']).default('observe'),
|
|
84
88
|
announce: s.boolean().default(false),
|
|
85
89
|
isolated: s.boolean().default(false),
|
|
90
|
+
forwardDirect: s.boolean().default(false),
|
|
86
91
|
agentName: s.string().default('DeepSeek Harness A2A node'),
|
|
87
92
|
peers: s.array(s.string()).default([]),
|
|
88
93
|
delegates: s.array(s.object({ name: s.string(), url: s.string(), publicKey: s.string().default('') })).default([]),
|
|
@@ -179,6 +184,8 @@ export const Config = s.object({
|
|
|
179
184
|
* t-mt6nd0sq-hxuhj6). 0 disables the nudge entirely.
|
|
180
185
|
*/
|
|
181
186
|
asyncNudgeDelayMs: s.number().default(120_000),
|
|
187
|
+
/** gh#224: at most this many receipt-owing tasks queue for one target; the next is refused (-32009). 0 disables. */
|
|
188
|
+
targetQueueCap: s.number().default(DEFAULT_TARGET_QUEUE_CAP),
|
|
182
189
|
stateColdRowsTtlMs: s.number().default(5_000),
|
|
183
190
|
cardCacheTtlMs: s.number().default(60_000),
|
|
184
191
|
cardCacheNegativeTtlMs: s.number().default(30_000),
|
|
@@ -755,7 +762,10 @@ export function apply(ctx, config) {
|
|
|
755
762
|
// message that still echoes a pending task id means the peer
|
|
756
763
|
// demonstrably responded to it, which settles the debt even when the
|
|
757
764
|
// receipt formality never rode the reply.
|
|
758
|
-
|
|
765
|
+
// #125 (owner data 2026-11-03: two production rows settled by a relay and by another task's receipt):
|
|
766
|
+
// an echo is weak evidence, so under observe it is judged as enforce — only an on-chain settler's echo
|
|
767
|
+
// settles. Formal receipt headers keep observing; settlementAuth 'off' still judges nothing.
|
|
768
|
+
const echoed = taskLedger.resolveEcho(message, judge !== undefined && !judge.enforce ? { ...judge, enforce: true } : judge);
|
|
759
769
|
if (echoed.length > 0) {
|
|
760
770
|
logger.info(`a2a: ${String(echoed.length)} pending task(s) settled by content echo (${echoed.join(', ')})`);
|
|
761
771
|
}
|
|
@@ -799,6 +809,8 @@ export function apply(ctx, config) {
|
|
|
799
809
|
// refused-but-idempotent, same-key different-payload answers conflict.
|
|
800
810
|
const idempotencyStore = new IdempotencyStore(join(home, 'a2a', 'idempotency.json'));
|
|
801
811
|
const refuseIfFlooded = createFloodGuard(idempotencyStore, recordActivity);
|
|
812
|
+
const ownTask = (taskId) => taskLedger.list().some(r => r.taskId === taskId) || taskLedger.archive().some(r => r.taskId === taskId);
|
|
813
|
+
const refuseIfQueueFull = createQueueGuard(() => taskLedger.list(), () => config.targetQueueCap, ownTask, idempotencyStore, recordActivity);
|
|
802
814
|
// Unmasked-identity suppression: gossip mirrors peer lists back and forth,
|
|
803
815
|
// so a URL whose fetch just proved it serves this node — or an already
|
|
804
816
|
// tracked node (an alias spelling) — keeps coming home as an inbound
|
|
@@ -1639,7 +1651,7 @@ export function apply(ctx, config) {
|
|
|
1639
1651
|
whenWebServerSettled((webServer) => {
|
|
1640
1652
|
// gh#77 B1: the admin faces live in src/faces/admin.ts.
|
|
1641
1653
|
registerAdminFaces(webServer, {
|
|
1642
|
-
ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerFetch, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, ownCanvasHandleOf, localSessionOfHandle, canvasTeamsOfSession, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, verifyOriginClaim, callerLinkedToThisHost, cachedCardFetch, authorizationView,
|
|
1654
|
+
ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerFetch, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, cliHomeForAdd, cliHomeOptions, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, ownCanvasHandleOf, localSessionOfHandle, canvasTeamsOfSession, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, verifyOriginClaim, callerLinkedToThisHost, cachedCardFetch, authorizationView,
|
|
1643
1655
|
indexUrl: import.meta.url,
|
|
1644
1656
|
get cardCache() { return cardCache; },
|
|
1645
1657
|
get remoteRowsCache() { return remoteRowsCache; },
|
|
@@ -1771,7 +1783,7 @@ export function apply(ctx, config) {
|
|
|
1771
1783
|
// deposit stays as an OFFLINE row (tombstone) so the board keeps
|
|
1772
1784
|
// the member card grayed-but-present and canvas refs never
|
|
1773
1785
|
// dangle silently; re-publish (same handle) un-tombstones.
|
|
1774
|
-
...cliDeposits.directoryRows().map(row => exposureRow(row.team, { name: row.name, description: row.description })),
|
|
1786
|
+
...cliDeposits.directoryRows().map(row => exposureRow(row.team, { name: row.name, description: row.description, offline: row.offline })),
|
|
1775
1787
|
];
|
|
1776
1788
|
const memberships = teamMemberships.list().flatMap(entry => entry.teams.map(team => ({ node: `${config.team}/${id8(entry.session)}`, team })));
|
|
1777
1789
|
const body = JSON.stringify({
|
|
@@ -2139,7 +2151,7 @@ export function apply(ctx, config) {
|
|
|
2139
2151
|
}
|
|
2140
2152
|
return { ok: false, error: 'No live DSH agent is available to accept this message.' };
|
|
2141
2153
|
}
|
|
2142
|
-
return { ok: false, error: wake.kind === 'no-face' ? noWakeFaceText(team) : wake.kind === 'archived' ? archivedColdMatchText(team) : noColdMatchText(team) };
|
|
2154
|
+
return { ok: false, ...(wake.kind === 'no-match' ? { localMiss: true } : {}), error: wake.kind === 'no-face' ? noWakeFaceText(team) : wake.kind === 'archived' ? archivedColdMatchText(team) : noColdMatchText(team) };
|
|
2143
2155
|
}
|
|
2144
2156
|
/**
|
|
2145
2157
|
* Steer one direct route request into one named agent and resolve with its
|
|
@@ -2382,6 +2394,7 @@ export function apply(ctx, config) {
|
|
|
2382
2394
|
},
|
|
2383
2395
|
liveNodeIds: () => [...sessionNodes.values()].map(agent => String(agent.id)),
|
|
2384
2396
|
cliDeposited: handle => cliDeposits.get(handle) !== undefined,
|
|
2397
|
+
cliHomeOf: handle => canvasStore.cliHomeOf(handle),
|
|
2385
2398
|
};
|
|
2386
2399
|
/** W1/W2 shared-team admission (see sameZoneAdmission in src/task-auth.ts). */
|
|
2387
2400
|
function sameZoneSharedTeamRefusal(caller, targetTeam) {
|
|
@@ -2393,8 +2406,8 @@ export function apply(ctx, config) {
|
|
|
2393
2406
|
return verdict === undefined ? undefined : refusalOf(verdict);
|
|
2394
2407
|
}
|
|
2395
2408
|
/** Inbound admission with the rule and shared teams that admitted the caller (gh#114 S1). */
|
|
2396
|
-
function a2aCallerAdmission(caller, targetTeam) {
|
|
2397
|
-
return callerAdmission(admissionRecords, caller, targetTeam);
|
|
2409
|
+
function a2aCallerAdmission(caller, targetTeam, cliProof) {
|
|
2410
|
+
return callerAdmission(admissionRecords, caller, targetTeam, cliProof);
|
|
2398
2411
|
}
|
|
2399
2412
|
function a2aCallerRefusal(caller, targetTeam) {
|
|
2400
2413
|
return refusalOf(a2aCallerAdmission(caller, targetTeam));
|
|
@@ -2418,6 +2431,7 @@ export function apply(ctx, config) {
|
|
|
2418
2431
|
// Local members by their handle (design §2.3), remote refs as they are stored.
|
|
2419
2432
|
canvas: canvasStore.list().map(name => [canvasTeamOf(name), canvasStore.membersOf(name).map(m => (m.includes('/') ? m : handle(m)))]),
|
|
2420
2433
|
cli: cliDeposits.list().map(row => row.handle),
|
|
2434
|
+
cliHome: canvasStore.pinnedCliHomes(),
|
|
2421
2435
|
links: config.peers.map(peer => { try {
|
|
2422
2436
|
return new URL(peer).origin;
|
|
2423
2437
|
}
|
|
@@ -2443,7 +2457,7 @@ export function apply(ctx, config) {
|
|
|
2443
2457
|
cause,
|
|
2444
2458
|
rows: () => taskLedger.list(),
|
|
2445
2459
|
// A local-candidate record (#139) is judged as the local candidate admits; every other one by the wire face.
|
|
2446
|
-
judge: (caller, target, ingress) => (ingress === 'local' ? localCandidateAdmission(admissionRecords, caller, target) : callerAdmission(admissionRecords, caller, target)),
|
|
2460
|
+
judge: (caller, target, ingress, auth) => auth.admission_rule === 'remote-ref-cli' ? recheckRemoteCliAuthorization(admissionRecords, auth) : (ingress === 'local' ? localCandidateAdmission(admissionRecords, caller, target) : callerAdmission(admissionRecords, caller, target)),
|
|
2447
2461
|
});
|
|
2448
2462
|
if (entry === undefined)
|
|
2449
2463
|
return;
|
|
@@ -2539,6 +2553,8 @@ export function apply(ctx, config) {
|
|
|
2539
2553
|
joinedSessions,
|
|
2540
2554
|
idempotencyStore,
|
|
2541
2555
|
verifyOriginClaim,
|
|
2556
|
+
remoteCliProof,
|
|
2557
|
+
forwardDirect: request => forwardDirect(request),
|
|
2542
2558
|
callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
|
|
2543
2559
|
recordActivity,
|
|
2544
2560
|
bumpLegacyInbound: () => { legacyInboundCount += 1; },
|
|
@@ -2551,6 +2567,7 @@ export function apply(ctx, config) {
|
|
|
2551
2567
|
readSessionLogTail,
|
|
2552
2568
|
taskLedger,
|
|
2553
2569
|
refuseIfFlooded,
|
|
2570
|
+
refuseIfQueueFull,
|
|
2554
2571
|
settleAndAnnounce,
|
|
2555
2572
|
touchWakeDemand: () => { lastWakeDemandAt = Date.now(); },
|
|
2556
2573
|
session,
|
|
@@ -2876,6 +2893,55 @@ export function apply(ctx, config) {
|
|
|
2876
2893
|
}
|
|
2877
2894
|
return { link: linked ? 'linked' : 'stranger', verified };
|
|
2878
2895
|
}
|
|
2896
|
+
async function freshCliHomeCards() {
|
|
2897
|
+
return await mapBounded(peerStore.list(), 6, async (url) => {
|
|
2898
|
+
// A failed read cannot prove a tracked host is unlinked (especially after a cold boot).
|
|
2899
|
+
// Conservatively leave automatic ownership unknown; explicit fresh choices still work.
|
|
2900
|
+
const card = await fetchPeerCard(url).catch(() => undefined);
|
|
2901
|
+
if (card !== undefined)
|
|
2902
|
+
cardCache.set(url, { at: Date.now(), card });
|
|
2903
|
+
return { url, card, linked: card === undefined || peerLinkState(url).link === 'linked' };
|
|
2904
|
+
});
|
|
2905
|
+
}
|
|
2906
|
+
async function cliHomeForAdd(handle, requested) {
|
|
2907
|
+
const localHousehold = () => cliDeposits.directoryRows().some(row => row.team === handle);
|
|
2908
|
+
if (localHousehold())
|
|
2909
|
+
return decideCliHome(handle, requested, [], true);
|
|
2910
|
+
return decideCliHome(handle, requested, CLI_HANDLE_RE.test(handle) ? await freshCliHomeCards() : [], localHousehold());
|
|
2911
|
+
}
|
|
2912
|
+
async function cliHomeOptions(handle) {
|
|
2913
|
+
const locallyHeld = () => cliDeposits.directoryRows().some(row => row.team === handle);
|
|
2914
|
+
if (locallyHeld())
|
|
2915
|
+
return cliHomeChoices(handle, [], true);
|
|
2916
|
+
return cliHomeChoices(handle, await freshCliHomeCards(), locallyHeld());
|
|
2917
|
+
}
|
|
2918
|
+
/** Remote CLI admission never trusts a retained stale cache entry after a failed home read. */
|
|
2919
|
+
async function remoteCliProof(handle, claim) {
|
|
2920
|
+
const home = canvasStore.cliHomeOf(handle);
|
|
2921
|
+
if (home === undefined || !claim.bound || claim.cli === true || !isRootOriginUrl(claim.callerUrl))
|
|
2922
|
+
return undefined;
|
|
2923
|
+
if (new URL(claim.callerUrl).origin !== home)
|
|
2924
|
+
return undefined;
|
|
2925
|
+
for (const url of peerStore.list()) {
|
|
2926
|
+
try {
|
|
2927
|
+
if (new URL(url).origin !== home)
|
|
2928
|
+
continue;
|
|
2929
|
+
}
|
|
2930
|
+
catch {
|
|
2931
|
+
continue;
|
|
2932
|
+
}
|
|
2933
|
+
const card = await fetchPeerCard(url);
|
|
2934
|
+
if (card === undefined || card.publicKey !== claim.card.publicKey)
|
|
2935
|
+
continue;
|
|
2936
|
+
cardCache.set(url, { at: Date.now(), card });
|
|
2937
|
+
if (peerLinkState(url).link !== 'linked')
|
|
2938
|
+
continue;
|
|
2939
|
+
const row = card.sessionTeams?.find(item => item.team === handle);
|
|
2940
|
+
if (row !== undefined)
|
|
2941
|
+
return { origin: home, viaUrl: claim.callerUrl, bound: true, online: row.offline === false };
|
|
2942
|
+
}
|
|
2943
|
+
return { origin: home, viaUrl: claim.callerUrl, bound: true, online: false };
|
|
2944
|
+
}
|
|
2879
2945
|
function cachedCardFetch(url) {
|
|
2880
2946
|
const hit = cardCache.get(url);
|
|
2881
2947
|
const now = Date.now();
|
|
@@ -3217,6 +3283,22 @@ export function apply(ctx, config) {
|
|
|
3217
3283
|
* caller label — the "能发即能收" invariant: whatever it addresses can
|
|
3218
3284
|
* answer it with one route.
|
|
3219
3285
|
*/
|
|
3286
|
+
const forwardDirect = createDirectForward({
|
|
3287
|
+
enabled: () => config.forwardDirect === true, isolated: () => config.isolated, deposited: handle => cliDeposits.get(handle) !== undefined,
|
|
3288
|
+
memberLinked: handle => canvasStore.list().some(name => canvasStore.remoteMembersOf(name).includes(handle)),
|
|
3289
|
+
scope: (handle, team) => config.teamScopeRouting ? outboundScope(senderScopeRecords, handle, handle, team) : Promise.resolve({ ok: true }),
|
|
3290
|
+
candidates: async (team, failures, caller) => { const fetch = memoizedCardFetch(); return { urls: await directoryPeerCandidates(fetch, team, failures, { id: caller, handle: caller }), card: fetch }; },
|
|
3291
|
+
dispatch: (url, request, guard) => dispatchPeerCandidate(url, request.team, request.message, request.contextId, new AbortController().signal, request.caller, request.noWait, request.taskId, request.caller, guard),
|
|
3292
|
+
ledger: taskLedger, activity: (team, peer, ok) => recordActivity('fwd', team, peer, ok),
|
|
3293
|
+
});
|
|
3294
|
+
const senderScopeRecords = {
|
|
3295
|
+
zone: config.team, id8, teamsOf: id => teamMemberships.teamsOf(id), membersOf: team => teamMemberships.membersOf(team),
|
|
3296
|
+
canvasNames: () => canvasStore.list(), canvasMembers: name => canvasStore.membersOf(name), canvasTeam: canvasTeamOf,
|
|
3297
|
+
peerOrgs: function* () { for (const hit of cardCache.values())
|
|
3298
|
+
if (hit.card?.canvasOrg)
|
|
3299
|
+
yield hit.card.canvasOrg; },
|
|
3300
|
+
warmPeers: () => mapBounded(resolutionPeers(), SWEEP_CONCURRENCY, cachedCardFetch),
|
|
3301
|
+
};
|
|
3220
3302
|
async function routeTrunk(args, agent, signal, hostCaller) {
|
|
3221
3303
|
// Fix ③ (S4 audit): host-side calls carry no agent identity — every one
|
|
3222
3304
|
// lands in the team audit ring, and the owner can flip the gate from
|
|
@@ -3252,62 +3334,11 @@ export function apply(ctx, config) {
|
|
|
3252
3334
|
}
|
|
3253
3335
|
catch { /* no initiator in this fiber */ }
|
|
3254
3336
|
if (!initiator) {
|
|
3255
|
-
const callerTeams = teamMemberships.teamsOf(scopeCaller);
|
|
3256
|
-
const team = String(args.team ?? '').trim();
|
|
3257
|
-
const sharedRoster = callerTeams.some(roster => teamMemberships.membersOf(roster).some(id => `${config.team}/${id8(id)}` === team));
|
|
3258
|
-
// gh#71: the SHARED-CANVAS basis and the warm cache's peer-org
|
|
3259
|
-
// basis (an org entry naming BOTH the caller and the target
|
|
3260
|
-
// handle) count as declared here too — the same link the
|
|
3261
|
-
// admission core honors on the receiving side; without this the
|
|
3262
|
-
// tool face refused routes the canvas itself had authorized.
|
|
3263
|
-
// Canvas members mix two shapes: LOCAL sessions sit by session
|
|
3264
|
-
// id, cross-host members by zone/id8 handle — match either.
|
|
3265
3337
|
const callerHandle = `${config.team}/${id8(scopeCaller)}`;
|
|
3266
3338
|
scopedCanvasCaller = { id: scopeCaller, handle: callerHandle };
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
// group's own team handle (canvasTeamOf(name)) is the match
|
|
3271
|
-
// (the caller sits in the very group being addressed).
|
|
3272
|
-
const sharedCanvas = canvasStore.list().some(name => {
|
|
3273
|
-
const members = canvasStore.membersOf(name);
|
|
3274
|
-
const inGroup = members.includes(scopeCaller) || members.includes(callerHandle);
|
|
3275
|
-
return inGroup ? members.includes(team) || canvasTeamOf(name) === team : false;
|
|
3276
|
-
});
|
|
3277
|
-
// A direct group route must not require a prior directory call. Fetch
|
|
3278
|
-
// only the same eligible peers as resolution; then apply the
|
|
3279
|
-
// unchanged sender/receiver admission rules to their verified cards.
|
|
3280
|
-
if (team.includes('/canvas/') && !sharedCanvas && !callerTeams.includes(team))
|
|
3281
|
-
await mapBounded(resolutionPeers(), SWEEP_CONCURRENCY, cachedCardFetch);
|
|
3282
|
-
let orgShared = false;
|
|
3283
|
-
for (const cached of cardCache.values()) {
|
|
3284
|
-
const org = cached.card?.canvasOrg;
|
|
3285
|
-
if (!Array.isArray(org))
|
|
3286
|
-
continue;
|
|
3287
|
-
for (const e of org) {
|
|
3288
|
-
if (typeof e.team !== 'string' || e.team === '' || !Array.isArray(e.members))
|
|
3289
|
-
continue;
|
|
3290
|
-
const inOrg = e.members.includes(scopeCaller) || e.members.includes(callerHandle);
|
|
3291
|
-
if (inOrg && (e.members.includes(team) || e.team === team)) {
|
|
3292
|
-
orgShared = true;
|
|
3293
|
-
break;
|
|
3294
|
-
}
|
|
3295
|
-
}
|
|
3296
|
-
if (orgShared)
|
|
3297
|
-
break;
|
|
3298
|
-
}
|
|
3299
|
-
if (callerTeams.length === 0 && !sharedRoster && !sharedCanvas && !orgShared) {
|
|
3300
|
-
return localRoute({
|
|
3301
|
-
ok: false,
|
|
3302
|
-
error: `team-scoped routing is on and this session (${id8(scopeCaller)}) has declared no team membership — a teamless node has no network. Declare one with a2a_team_join (subject to the owner's teamJoinAllowlist)`,
|
|
3303
|
-
});
|
|
3304
|
-
}
|
|
3305
|
-
if (!callerTeams.includes(team) && !sharedRoster && !sharedCanvas && !orgShared) {
|
|
3306
|
-
return localRoute({
|
|
3307
|
-
ok: false,
|
|
3308
|
-
error: `team-scoped routing is on: routing to "${team}" requires declared membership. This session's teams: ${callerTeams.join(', ')}. a2a_team_join can declare it (subject to the owner's teamJoinAllowlist)`,
|
|
3309
|
-
});
|
|
3310
|
-
}
|
|
3339
|
+
const scope = await outboundScope(senderScopeRecords, scopeCaller, callerHandle, String(args.team ?? '').trim());
|
|
3340
|
+
if (!scope.ok)
|
|
3341
|
+
return localRoute(scope);
|
|
3311
3342
|
}
|
|
3312
3343
|
}
|
|
3313
3344
|
let callerSession = resolveCallerLabel({
|
|
@@ -3470,6 +3501,12 @@ export function apply(ctx, config) {
|
|
|
3470
3501
|
recordActivity('out', team, callerSession ?? 'local', false);
|
|
3471
3502
|
return { ok: false, error: idRefusal, code: -32000 };
|
|
3472
3503
|
}
|
|
3504
|
+
// gh#224: a full target queue refuses a same-host delivery as the wire face does (receipts, CLI mailboxes pass).
|
|
3505
|
+
const queueRefusal = queueFullRefusal(taskLedger.list(), team, message, config.targetQueueCap, cliDeposits.get(team) !== undefined, ownTask);
|
|
3506
|
+
if (queueRefusal !== undefined) {
|
|
3507
|
+
recordActivity('out', team, callerSession ?? 'local', false);
|
|
3508
|
+
return { ok: false, error: queueRefusal, code: WIRE_ERROR_QUEUE_FULL };
|
|
3509
|
+
}
|
|
3473
3510
|
const canvasName = parseCanvasTeamName(team);
|
|
3474
3511
|
// Issue #41 shape note: the caller label is the WIRE form (<zone>/<id8>
|
|
3475
3512
|
// or a session label) and quick-dispatch BORROWS a live session's team
|
|
@@ -3739,9 +3776,9 @@ export function apply(ctx, config) {
|
|
|
3739
3776
|
* exactly like same-host ones; older peers that ignore the flag simply
|
|
3740
3777
|
* keep the blocking wait (graceful degradation).
|
|
3741
3778
|
*/
|
|
3742
|
-
async function dispatchPeerCandidate(url, team, message, contextId, signal, callerSession, asyncMode = false, taskIdFromCaller, callbackAddress) {
|
|
3779
|
+
async function dispatchPeerCandidate(url, team, message, contextId, signal, callerSession, asyncMode = false, taskIdFromCaller, callbackAddress, guard) {
|
|
3743
3780
|
const flight = beginRoute(team, url);
|
|
3744
|
-
const result = await client.routeDirect(url, team, message, contextId, signal, callerSession, asyncMode, taskIdFromCaller, callbackAddress);
|
|
3781
|
+
const result = await client.routeDirect(url, team, message, contextId, signal, callerSession, asyncMode, taskIdFromCaller, callbackAddress, guard);
|
|
3745
3782
|
endRoute(flight);
|
|
3746
3783
|
recordActivity('out', team, url, result.ok);
|
|
3747
3784
|
// F1 half-transaction compensation (t-mth5gmxy): the client's 15s budget
|
|
@@ -3778,6 +3815,18 @@ export function apply(ctx, config) {
|
|
|
3778
3815
|
* owner escalation actually fires for cross-node callback addresses.
|
|
3779
3816
|
*/
|
|
3780
3817
|
async function dispatchAnywhere(team, message, callerSession, contextId, signal, asyncMode, taskId, peerCaller) {
|
|
3818
|
+
// A local online household is an owner-established fact and receives locally.
|
|
3819
|
+
// Otherwise only the pinned home may receive; never fall back to directory claims.
|
|
3820
|
+
const cliReceipt = CLI_HANDLE_RE.test(team) && parseReceipt(message) !== null;
|
|
3821
|
+
if (cliReceipt && cliDeposits.get(team) === undefined) {
|
|
3822
|
+
const home = canvasStore.cliHomeOf(team);
|
|
3823
|
+
if (home === undefined)
|
|
3824
|
+
throw new Error(`CLI receipt home is not pinned for ${team}`);
|
|
3825
|
+
const delivered = await dispatchPeerCandidate(home, team, message, contextId, signal, peerCaller ?? callerSession, asyncMode, taskId);
|
|
3826
|
+
if (!delivered.ok)
|
|
3827
|
+
throw new Error(`CLI receipt to pinned home failed: ${delivered.error}`);
|
|
3828
|
+
return delivered;
|
|
3829
|
+
}
|
|
3781
3830
|
const local = await dispatchLocalCandidate(team, message, callerSession, contextId, signal, asyncMode, taskId);
|
|
3782
3831
|
// A background send books no outbound row: its birth facts have no row to go on.
|
|
3783
3832
|
if (taskId !== undefined)
|
|
@@ -3787,6 +3836,8 @@ export function apply(ctx, config) {
|
|
|
3787
3836
|
throw new Error(local.error);
|
|
3788
3837
|
return local;
|
|
3789
3838
|
}
|
|
3839
|
+
if (cliReceipt)
|
|
3840
|
+
throw new Error(`local CLI household expired before delivery to ${team}; directory fallback refused`);
|
|
3790
3841
|
const failures = [];
|
|
3791
3842
|
const candidates = await directoryPeerCandidates(memoizedCardFetch(), team, failures);
|
|
3792
3843
|
if (candidates.length === 0) {
|
|
@@ -3844,7 +3895,8 @@ export function apply(ctx, config) {
|
|
|
3844
3895
|
const cards = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }));
|
|
3845
3896
|
// Check after fetching all cards: a seeded peer publishing the same id8
|
|
3846
3897
|
// makes the caller handle ambiguous, so do not send a canvas payload.
|
|
3847
|
-
const
|
|
3898
|
+
const sessions = scopedCanvasCaller === undefined ? undefined : localHandleResolver();
|
|
3899
|
+
const resolveLocal = sessions === undefined ? undefined : (ref) => cliDeposits.get(ref) !== undefined ? ref : sessions(ref);
|
|
3848
3900
|
const resolutions = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
|
|
3849
3901
|
if (canvasTargetAmbiguous(cards.map(entry => entry.card), team, scopedCanvasCaller, resolveLocal, resolutions.some(entry => entry.outcome.ok))) {
|
|
3850
3902
|
failures.push(`ambiguous: team "${team}" has conflicting owners, identities, or a colliding caller; no payload sent`);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export async function outboundScope(r, id, handle, team) {
|
|
2
|
+
const teams = r.teamsOf(id);
|
|
3
|
+
const sharedRoster = teams.some(roster => r.membersOf(roster).some(member => r.zone + '/' + r.id8(member) === team));
|
|
4
|
+
const sharedCanvas = r.canvasNames().some(name => {
|
|
5
|
+
const members = r.canvasMembers(name);
|
|
6
|
+
return (members.includes(id) || members.includes(handle)) && (members.includes(team) || r.canvasTeam(name) === team);
|
|
7
|
+
});
|
|
8
|
+
if (team.includes('/canvas/') && !sharedCanvas && !teams.includes(team))
|
|
9
|
+
await r.warmPeers();
|
|
10
|
+
let orgShared = false;
|
|
11
|
+
for (const org of r.peerOrgs())
|
|
12
|
+
for (const row of org) {
|
|
13
|
+
if (typeof row.team !== 'string' || row.team === '' || !Array.isArray(row.members))
|
|
14
|
+
continue;
|
|
15
|
+
if ((row.members.includes(id) || row.members.includes(handle)) && (row.members.includes(team) || row.team === team)) {
|
|
16
|
+
orgShared = true;
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (teams.length === 0 && !sharedRoster && !sharedCanvas && !orgShared)
|
|
21
|
+
return { ok: false, error: `team-scoped routing is on and this session (${r.id8(id)}) has declared no team membership — a teamless node has no network. Declare one with a2a_team_join (subject to the owner's teamJoinAllowlist)` };
|
|
22
|
+
if (!teams.includes(team) && !sharedRoster && !sharedCanvas && !orgShared)
|
|
23
|
+
return { ok: false, error: `team-scoped routing is on: routing to "${team}" requires declared membership. This session's teams: ${teams.join(', ')}. a2a_team_join can declare it (subject to the owner's teamJoinAllowlist)` };
|
|
24
|
+
return { ok: true };
|
|
25
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { parseReceipt } from "./receipt.js";
|
|
2
|
+
export const WIRE_ERROR_QUEUE_FULL = -32009;
|
|
3
|
+
export const DEFAULT_TARGET_QUEUE_CAP = 3;
|
|
4
|
+
/** How many receipt-owing tasks this host holds pending for `team`. */
|
|
5
|
+
export function queuedFor(rows, team) {
|
|
6
|
+
return rows.filter(row => row.status === 'pending' && row.receiptExpected !== false &&
|
|
7
|
+
(row.direction === 'inbound' || row.peer === 'local') &&
|
|
8
|
+
(row.team === team || row.deliveredTo === team)).length;
|
|
9
|
+
}
|
|
10
|
+
/** The refusal text when `team`'s queue is full, else undefined. `cap <= 0` disables the guard. */
|
|
11
|
+
export function queueFullRefusal(rows, team, message, cap, mailbox, ownTask) {
|
|
12
|
+
// A config without the key (old patch layer, test harness) gets the shipped default, not "no cap".
|
|
13
|
+
const limit = typeof cap === 'number' && Number.isFinite(cap) ? cap : DEFAULT_TARGET_QUEUE_CAP;
|
|
14
|
+
if (limit <= 0 || mailbox)
|
|
15
|
+
return undefined;
|
|
16
|
+
const receipt = parseReceipt(message);
|
|
17
|
+
if (receipt !== null && receipt.taskId !== '' && ownTask(receipt.taskId))
|
|
18
|
+
return undefined;
|
|
19
|
+
const queued = queuedFor(rows, team);
|
|
20
|
+
if (queued < limit)
|
|
21
|
+
return undefined;
|
|
22
|
+
return `target-queue-full (gh#224): "${team}" already has ${String(queued)} task(s) waiting for a receipt (cap ${String(limit)}) — nothing was delivered. Wait for a receipt from it (a2a inbox / a2a_tasks), or send again later with a NEW task id (this one is recorded as failed); do not resend in a loop. A task that never gets a receipt turns dead after taskStaleTtlMs and stops counting.`;
|
|
23
|
+
}
|
|
24
|
+
/** Wire-face adapter in the flood guard's shape: a refused fresh key is FAILED, not stranded pending. */
|
|
25
|
+
export function createQueueGuard(rows, cap, ownTask, outcomes, recordActivity) {
|
|
26
|
+
return (caller, message, team, taskId, mailbox, res) => {
|
|
27
|
+
const error = queueFullRefusal(rows(), team, message, cap(), mailbox, ownTask);
|
|
28
|
+
if (error === undefined)
|
|
29
|
+
return false;
|
|
30
|
+
outcomes.recordOutcome(taskId, { status: 'failed', error });
|
|
31
|
+
recordActivity('in', team, caller, false);
|
|
32
|
+
const payload = JSON.stringify({ error, reason: 'target-queue-full', code: WIRE_ERROR_QUEUE_FULL, task_id: taskId, team, delivered: false, task_status: 'TASK_STATE_FAILED' });
|
|
33
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
34
|
+
res.end(payload);
|
|
35
|
+
return true;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Add structured fields to any JSON response exit and recompute its Content-Length. */
|
|
2
|
+
export function withResponseFields(res, fields) {
|
|
3
|
+
const writeHead = res.writeHead.bind(res);
|
|
4
|
+
const end = res.end.bind(res);
|
|
5
|
+
let head;
|
|
6
|
+
res.writeHead = (status, headers) => {
|
|
7
|
+
head = { status, headers: { ...(headers ?? {}) } };
|
|
8
|
+
return res;
|
|
9
|
+
};
|
|
10
|
+
res.end = (body) => {
|
|
11
|
+
let out = body;
|
|
12
|
+
if (typeof body === 'string') {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(body);
|
|
15
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
16
|
+
out = JSON.stringify({ ...parsed, ...fields });
|
|
17
|
+
}
|
|
18
|
+
catch { /* not JSON: unchanged */ }
|
|
19
|
+
}
|
|
20
|
+
if (head !== undefined) {
|
|
21
|
+
if (out !== undefined && 'Content-Length' in head.headers)
|
|
22
|
+
head.headers['Content-Length'] = Buffer.byteLength(out);
|
|
23
|
+
writeHead(head.status, head.headers);
|
|
24
|
+
}
|
|
25
|
+
return end(out);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -42,8 +42,14 @@ model tools.
|
|
|
42
42
|
405 with an empty body. The fix is a host upgrade, not a retry.
|
|
43
43
|
- `a2a-net revoke` deregisters early.
|
|
44
44
|
|
|
45
|
+
## Tasks: ledger and host observability
|
|
46
|
+
|
|
47
|
+
`a2a-net tasks --json` returns the existing `pending`, `dead`, and `archived` arrays plus `settlement` and `conservation` copied unchanged from `/__dsh_a2a/state`. These blocks include the lifetime `settlement.refusalsTotal`, refusal ring and mode, and the host conservation verdict/counters/duplicate ids. Older hosts omit a block they do not expose; the CLI does not manufacture zero counters or a passing verdict. Plain `tasks` appends a conservation line (`conserved`, `delta`) and a settlement line (`mode`, `refusalsTotal`, `offChain`) when the respective block exists.
|
|
48
|
+
|
|
45
49
|
## Files (#91 file channel)
|
|
46
50
|
|
|
51
|
+
Files do not go through session messages: use the file channel below, never JPFILE-PART/base64 chunks in `route`. Legacy part replies carry `advice.reason: use-file-channel` with the send commands; up to 16 compatible parts are still allowed, while over-limit transfers keep `-32008` / `file-parts-exceeded`. The hint alone does not mean rejection.
|
|
52
|
+
|
|
47
53
|
- `a2a-net file send <path…> <team> [--note TEXT]` stages each file (≤64 MiB,
|
|
48
54
|
≤16 per send) on the home host by sha256, granted to `<team>` for 24h, and
|
|
49
55
|
routes ONE message with one `[A2A file] v1 sha256=… size=… name=… from=<home>`
|
|
@@ -111,10 +117,11 @@ model tools.
|
|
|
111
117
|
- Reusing an id for a different message, or switching `--sync` on or off,
|
|
112
118
|
is refused (exit 1): drop `--task` to send it as a new task.
|
|
113
119
|
- `report` takes `--task` the same way.
|
|
114
|
-
-
|
|
115
|
-
|
|
116
|
-
-
|
|
117
|
-
|
|
120
|
+
- Home-host forwarding is opt-in: `forwardDirect: true` forwards one hop only after a local target miss. H must not be isolated, the CLI must be online and sign a bound envelope, the inbound request must have no Guard, and the target must pass H’s sender scope.
|
|
121
|
+
- Both operators establish links: at A, put the CLI and B receiving member handles in an operator canvas; at B, link the CLI handle and pin its home to A. A remote canvas target requires A to directly link every advertised receiving member; a peer-org claim alone is insufficient for the return receipt.
|
|
122
|
+
- B signs its auto-receipt as the member that did the work. Its destination prefers an online CLI household on that host (an owner-controlled fact); otherwise it returns only to the pinned home, never another directory advertiser. In the normal single-home setup it settles A’s single forwarded outbound row on-chain under `enforce` and enters the CLI mailbox (`a2a-net inbox`). `--sync` returns B’s final reply. A 15s transport-budget expiry is `-32005`; reconcile by task id rather than resend blindly.
|
|
123
|
+
- With forwarding off or any prerequisite missing, the original "No live DSH session node accepts team" error includes the reason it was not forwarded. Receiving hosts never forward the forwarded request again.
|
|
124
|
+
- Admission: a deposited handle is a node fact of its home host (N3); that fact alone grants no remote scope or admission. Operator canvas membership and B’s home pin are still required. Global self-description is never a link.
|
|
118
125
|
- `a2a-net report <message…>` is a one-way report to the host's process team.
|
|
119
126
|
- A message that starts with `-` goes after `--`. Flag values never become
|
|
120
127
|
part of the message.
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
- **adopt-style revive only works for previously materialized sessions** —
|
|
10
10
|
it is an idempotent revive, not a creation path.
|
|
11
11
|
|
|
12
|
+
## Files
|
|
13
|
+
|
|
14
|
+
- **Files do not go through session messages.** Use `a2a_file_send { paths, team }` (up to 16 files, 64 MiB each), then `a2a_file_fetch { ref }` on the received `[A2A file] v1 …` reference. CLI: `a2a-net file send <path…> <team>` and `a2a-net file fetch "<reference line>"`. Do not base64/chunk files into JPFILE-PART messages. During compatibility rollout, accepted legacy parts carry `advice.reason: use-file-channel`; over-limit parts still fail with `-32008` / `file-parts-exceeded` and the same advice. Do not resend rejected parts in a loop.
|
|
15
|
+
|
|
12
16
|
## Routing
|
|
13
17
|
|
|
14
18
|
- **Do not blindly re-dispatch after a sync timeout.** -32005 means the
|
|
@@ -20,10 +24,7 @@
|
|
|
20
24
|
The `a2a-net` CLI (0.6.27+) is the supported exception: a DEPOSITED handle
|
|
21
25
|
is routable on its home host and receives into its mailbox
|
|
22
26
|
([cli.md](cli.md)).
|
|
23
|
-
- **Direct delivery
|
|
24
|
-
the host that receives it; a team on another host answers "No live DSH
|
|
25
|
-
session node accepts team". `a2a_route` resolves peers and forwards;
|
|
26
|
-
direct callers (the CLI included) must target their home host's teams.
|
|
27
|
+
- **"No live DSH session node accepts team" may name a forwarding prerequisite.** Direct delivery remains local by default. `forwardDirect: true` permits one hop only for an online, bound-signed local CLI, on a non-isolated home, with no inbound Guard and a target inside sender scope. A must link B’s receiving members; B must link the CLI and pin A as its home. Remote canvas org alone cannot authorize the receipt return path. Read the appended "not forwarded" reason; do not treat a missing link, expired deposit or already-forwarded request as a transport outage. The target host never forwards again.
|
|
27
28
|
- A sync route to a remote peer rides a 15s transport budget (a target on
|
|
28
29
|
this host gets 180s) — both are shorter than many model turns. For
|
|
29
30
|
anything that runs long, prefer `async: true` and the receipt contract.
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Routing deep-dive
|
|
2
2
|
|
|
3
|
+
## Files use the file channel
|
|
4
|
+
|
|
5
|
+
Files do not go through session messages. Send with `a2a_file_send { paths, team }` (up to 16 files, 64 MiB each), not `a2a_route` with file bytes or JPFILE-PART chunks. The receiver uses `a2a_file_fetch { ref }` with the `[A2A file] v1 …` line. CLI: `a2a-net file send <path…> <team>` / `a2a-net file fetch "<reference line>"`. One reference message carries the transfer; ordinary routing and receipt rules below apply to that message. Legacy parts still allowed by the transitional cap receive `advice.reason: use-file-channel`; this hint is not a refusal or a completion receipt.
|
|
6
|
+
|
|
3
7
|
## The receipt contract
|
|
4
8
|
|
|
5
9
|
Every async route (`wait:false`) registers the task on the peer and returns
|