@nelsonlongxiang/dsh-open-a2a-net 0.6.30 → 0.6.32
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 +4 -1
- package/README_zh.md +3 -0
- package/assets/nexusDist/assets/index-D5H8R8de.js +9 -0
- package/assets/nexusDist/assets/index-D9GYZyGZ.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +445 -57
- package/lib/a2a-client.js +9 -4
- package/lib/auth-observer.js +254 -0
- package/lib/blob-store.js +210 -0
- package/lib/card.js +4 -2
- package/lib/cli-deposit.js +122 -7
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +133 -43
- package/lib/faces/canvas-notify.js +12 -3
- package/lib/faces/files.js +155 -0
- package/lib/faces/nodes.js +171 -0
- package/lib/faces/session-log.js +162 -0
- package/lib/faces/wire.js +206 -39
- package/lib/index.js +809 -1720
- package/lib/joined-store.js +23 -0
- package/lib/ledger-conservation.js +97 -0
- package/lib/legacy/teams-bridge-inbound.js +156 -0
- package/lib/node-bind.js +534 -0
- package/lib/node-github.js +72 -0
- package/lib/node-self.js +453 -0
- package/lib/node-store.js +484 -0
- package/lib/node-tasks.js +342 -0
- package/lib/origin-claim.js +246 -0
- package/lib/peer-directory.js +134 -0
- package/lib/receipt.js +26 -1
- package/lib/skill/SKILL.md +3 -1
- package/lib/skill/references/cli.md +82 -3
- package/lib/skill/references/collaboration.md +146 -73
- package/lib/skill/references/discovery.md +29 -0
- package/lib/skill/references/intake.md +107 -0
- package/lib/skill/references/router.md +96 -0
- package/lib/skill/references/setup.md +2 -1
- package/lib/task-auth.js +483 -0
- package/lib/task-ledger.js +324 -12
- package/lib/team-store.js +24 -1
- package/lib/tools/act.js +383 -0
- package/lib/tools/files.js +201 -0
- package/lib/tools/info.js +684 -0
- package/lib/types/a2a-client.d.ts +10 -4
- package/lib/types/auth-observer.d.ts +110 -0
- package/lib/types/blob-store.d.ts +83 -0
- package/lib/types/cli-deposit.d.ts +38 -0
- package/lib/types/faces/admin.d.ts +14 -2
- package/lib/types/faces/canvas-notify.d.ts +4 -1
- package/lib/types/faces/files.d.ts +53 -0
- package/lib/types/faces/nodes.d.ts +69 -0
- package/lib/types/faces/session-log.d.ts +44 -0
- package/lib/types/faces/wire.d.ts +50 -5
- package/lib/types/index.d.ts +27 -0
- package/lib/types/joined-store.d.ts +9 -0
- package/lib/types/ledger-conservation.d.ts +77 -0
- package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
- package/lib/types/node-bind.d.ts +220 -0
- package/lib/types/node-github.d.ts +35 -0
- package/lib/types/node-self.d.ts +178 -0
- package/lib/types/node-store.d.ts +180 -0
- package/lib/types/node-tasks.d.ts +121 -0
- package/lib/types/origin-claim.d.ts +119 -0
- package/lib/types/peer-directory.d.ts +39 -0
- package/lib/types/receipt.d.ts +12 -0
- package/lib/types/task-auth.d.ts +271 -0
- package/lib/types/task-ledger.d.ts +143 -6
- package/lib/types/team-store.d.ts +9 -0
- package/lib/types/tools/act.d.ts +52 -0
- package/lib/types/tools/files.d.ts +38 -0
- package/lib/types/tools/info.d.ts +102 -0
- package/lib/types/types.d.ts +6 -0
- package/nexus-stage/src/canvas-ops.ts +17 -2
- package/nexus-stage/src/canvas-wire.ts +27 -19
- package/nexus-stage/src/federation.ts +1 -0
- package/nexus-stage/src/main.ts +41 -18
- package/nexus-stage/src/planning-member-handles.ts +34 -0
- package/nexus-stage/src/planning-mirror.ts +47 -3
- package/nexus-stage/src/planning-reachability.ts +28 -0
- package/nexus-stage/src/planning-types.ts +1 -1
- package/nexus-stage/src/planning-view.ts +30 -27
- package/nexus-stage/src/planning.css +4 -0
- package/nexus-stage/src/wb-directory.ts +13 -0
- package/nexus-stage/src/workbench.css +3 -0
- package/nexus-stage/src/workbench.ts +5 -1
- package/package.json +1 -1
- package/assets/nexusDist/assets/index-C_x3iAth.js +0 -9
- package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The information-plane model tools (gh#77 B3-R): whoami / teams / status /
|
|
3
|
+
* tasks / probe moved verbatim out of apply() in src/index.ts. Registration
|
|
4
|
+
* order is preserved by the caller invoking each registrar at the original
|
|
5
|
+
* tool position; the joined-team scope helpers and describeAge moved with the
|
|
6
|
+
* tools because only these tools use them. SWEEP_CONCURRENCY arrives through
|
|
7
|
+
* a getter so the deps object can be built before the const is declared.
|
|
8
|
+
* @module src/tools/info
|
|
9
|
+
*/
|
|
10
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
11
|
+
import { peerDirectoryRows } from "../peer-directory.js";
|
|
12
|
+
import { peerContentMeta } from "../node-self.js";
|
|
13
|
+
/**
|
|
14
|
+
* Build the five registrars. Call createInfoTools after its deps exist, then
|
|
15
|
+
* invoke each registrar at the tool's original registration position — the
|
|
16
|
+
* registry order the model sees must not change.
|
|
17
|
+
*/
|
|
18
|
+
export function createInfoTools(deps) {
|
|
19
|
+
const { ctx, config, logger, session, lanIp, ownCardUrl, peerStore, taskLedger, joinedSessions, teamMemberships, canvasStore, exposureStore, idempotencyStore, cardCache, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, id8, sessionTeamOf, nodeMetadataOf, canvasTeamOf, canvasTeamsOfSession, localHandleResolver, memoizedCardFetch, mapBounded, SWEEP_CONCURRENCY, a2aJoinGateRefusal, observeAuthorization, settlePeerCard, client, listDirectoryTeams, } = deps;
|
|
20
|
+
// S5 (owner directive 2026-10-20): information-plane scoping. The host's
|
|
21
|
+
// joined-team scope is the union of every local node's roster
|
|
22
|
+
// declarations — a host-level set, not per-session: supervisor seats need
|
|
23
|
+
// the cross-team overview, and 「加入的团队」 reads as the teams this
|
|
24
|
+
// host's nodes joined. The information-plane tools (a2a_teams /
|
|
25
|
+
// a2a_status / a2a_probe) disclose remote facts only inside that scope;
|
|
26
|
+
// a2a_tasks — this host's own dispatch ledger and receipt-reconciliation
|
|
27
|
+
// surface, including initiator-exempt dispatches — stays unscoped by
|
|
28
|
+
// ruling (its rows name teams this host itself routed to, never a fleet
|
|
29
|
+
// census).
|
|
30
|
+
const joinedTeamScope = () => {
|
|
31
|
+
const joined = new Set();
|
|
32
|
+
for (const entry of teamMemberships.list()) {
|
|
33
|
+
for (const team of entry.teams)
|
|
34
|
+
joined.add(team);
|
|
35
|
+
}
|
|
36
|
+
// G4 (review artifact 688c0354d3 v2): canvas membership is join intent
|
|
37
|
+
// too. This host's canvas teams — and every member handle they name
|
|
38
|
+
// (remote zone-qualified handles as-is, local session ids as their
|
|
39
|
+
// session-team handle) — belong to the scope, so canvas teammates stay
|
|
40
|
+
// visible to each other under teamScopeInfo instead of the roster-only
|
|
41
|
+
// reading splitting them apart.
|
|
42
|
+
for (const name of canvasStore.list()) {
|
|
43
|
+
joined.add(canvasTeamOf(name));
|
|
44
|
+
for (const member of canvasStore.membersOf(name)) {
|
|
45
|
+
joined.add(member.includes('/') ? member : `${config.team}/${id8(member)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return joined;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Match exactly the sanitized rows the directory would expose: process/session
|
|
52
|
+
* and roster teams use their joined names; canvas rows need an explicitly
|
|
53
|
+
* joined local member resolved by the same local-first handle rules.
|
|
54
|
+
*/
|
|
55
|
+
const cardServesJoined = (card, joined, resolveLocal) => peerDirectoryRows(card, '', true).some(row => {
|
|
56
|
+
if (row.canvasMembers !== undefined) {
|
|
57
|
+
return row.canvasMembers.some(ref => {
|
|
58
|
+
const id = resolveLocal(ref);
|
|
59
|
+
return id !== undefined && joinedSessions.has(id);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return joined.has(row.team) || (row.teams ?? []).some(team => joined.has(team));
|
|
63
|
+
});
|
|
64
|
+
/** Human duration for a ledger row: how long a task has been owed or took. */
|
|
65
|
+
const describeAge = (ms) => {
|
|
66
|
+
const minutes = Math.floor(ms / 60_000);
|
|
67
|
+
if (minutes < 1)
|
|
68
|
+
return 'under a minute';
|
|
69
|
+
if (minutes < 60)
|
|
70
|
+
return `${String(minutes)}m`;
|
|
71
|
+
return `${String(Math.floor(minutes / 60))}h${minutes % 60 === 0 ? '' : `${String(minutes % 60)}m`}`;
|
|
72
|
+
};
|
|
73
|
+
// S5 ruling: a2a_tasks stays unscoped — it is this host's own dispatch
|
|
74
|
+
// ledger (pending / dead / archived), the receipt-reconciliation surface
|
|
75
|
+
// for work this host itself routed, initiator-exempt dispatches included.
|
|
76
|
+
// Its rows name teams this host already talked to; reading them back is
|
|
77
|
+
// bookkeeping, not fleet disclosure.
|
|
78
|
+
function registerWhoami() {
|
|
79
|
+
// K3 feedback P0 (2026-10-20): a session could appear in a2a_teams, route
|
|
80
|
+
// fine, and still be refused by a2a_team_join — with no way to ask "what am
|
|
81
|
+
// I, actually?". whoami is that one-call answer. Deliberately NOT gated by
|
|
82
|
+
// a2aJoinGateRefusal: the unjoined session is precisely the caller that
|
|
83
|
+
// needs it.
|
|
84
|
+
ctx.tools.register(defineTool({
|
|
85
|
+
name: 'a2a_whoami',
|
|
86
|
+
description: 'One-call self-introspection on this host\'s A2A network: the calling session\'s node id, joined/live '
|
|
87
|
+
+ 'state, routable team handle, declared team memberships (roster + canvas), the owner\'s teamJoinAllowlist, '
|
|
88
|
+
+ 'the teamScopeRouting switch, and this node\'s most recent refused join/leave attempts with reasons. '
|
|
89
|
+
+ 'Note: outbound routing never requires join — the originating session is always exempt (initiator face); '
|
|
90
|
+
+ 'join buys presence, discovery, and being routed to.',
|
|
91
|
+
parameters: {},
|
|
92
|
+
output: {
|
|
93
|
+
schema: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
additionalProperties: false,
|
|
96
|
+
properties: {
|
|
97
|
+
ok: { type: 'boolean', required: true },
|
|
98
|
+
face: { type: 'string', required: true, description: 'session | host' },
|
|
99
|
+
node: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
additionalProperties: true,
|
|
102
|
+
description: 'id, joined, live, and the routable team handle (zone/id8) once joined.',
|
|
103
|
+
},
|
|
104
|
+
teams: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
additionalProperties: true,
|
|
107
|
+
description: 'roster: allowlisted teams declared via a2a_team_join; canvas: workbench-assigned teams.',
|
|
108
|
+
},
|
|
109
|
+
allowlist: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
additionalProperties: true,
|
|
112
|
+
description: 'The owner-curated teamJoinAllowlist: exact entries (the only join authority, gh#11) plus pattern entries kept readable but carrying no mint rights (deny-all / curated; allow-all only via explicit exact entries).',
|
|
113
|
+
},
|
|
114
|
+
teamScopeRouting: { type: 'boolean' },
|
|
115
|
+
note: { type: 'string', description: 'What join does and does not gate, in one sentence.' },
|
|
116
|
+
lastRefusals: {
|
|
117
|
+
type: 'array',
|
|
118
|
+
description: 'Most recent refused join/leave attempts for this node (newest first, ≤3), with reason codes.',
|
|
119
|
+
items: { type: 'object', additionalProperties: true },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
render: (_args, value) => [{
|
|
124
|
+
type: 'text',
|
|
125
|
+
text: value.face === 'host'
|
|
126
|
+
? `Host face on team ${String(value.node?.team ?? '?')}: routing rides the bare node team (能发即能收)`
|
|
127
|
+
: `Node ${String(value.node?.id ?? '?')}: joined=${String(value.node?.joined)}, live=${String(value.node?.live)}; roster teams: ${(value.teams?.roster ?? []).join(', ') || 'none'}; canvas: ${(value.teams?.canvas ?? []).join(', ') || 'none'}; allowlist effect: ${String(value.allowlist?.effect ?? '?')}`,
|
|
128
|
+
}],
|
|
129
|
+
},
|
|
130
|
+
presentCall: () => ({ card: 'generic', title: 'A2A whoami', kind: 'other', rawInput: null }),
|
|
131
|
+
execute: async (_args, exec) => {
|
|
132
|
+
const agentId = exec?.agent !== undefined ? String(exec.agent.id) : '';
|
|
133
|
+
if (agentId === '') {
|
|
134
|
+
return {
|
|
135
|
+
ok: true,
|
|
136
|
+
face: 'host',
|
|
137
|
+
node: { team: config.team },
|
|
138
|
+
note: 'host face — routing rides the bare node team (能发即能收); joins are session-node concerns',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const joined = joinedSessions.has(agentId);
|
|
142
|
+
const live = sessionNodes.has(agentId);
|
|
143
|
+
const refusals = teamAuditRing
|
|
144
|
+
.filter(entry => entry.id === agentId && !entry.ok)
|
|
145
|
+
.slice(-3)
|
|
146
|
+
.reverse()
|
|
147
|
+
.map(entry => ({ at: entry.at, action: entry.action, team: entry.team, reason: entry.reason ?? '' }));
|
|
148
|
+
const patterns = [...deps.ownerAllowlist];
|
|
149
|
+
return {
|
|
150
|
+
ok: true,
|
|
151
|
+
face: 'session',
|
|
152
|
+
node: {
|
|
153
|
+
id: agentId,
|
|
154
|
+
...(joined ? { team: `${config.team}/${id8(agentId)}` } : {}),
|
|
155
|
+
joined,
|
|
156
|
+
live,
|
|
157
|
+
},
|
|
158
|
+
teams: {
|
|
159
|
+
roster: [...teamMemberships.teamsOf(agentId)],
|
|
160
|
+
canvas: [...canvasTeamsOfSession(agentId)],
|
|
161
|
+
},
|
|
162
|
+
allowlist: {
|
|
163
|
+
patterns,
|
|
164
|
+
effect: patterns.length === 0 ? 'deny-all (empty allowlist refuses every join)' : patterns.includes('*') ? 'allow-all' : 'curated',
|
|
165
|
+
},
|
|
166
|
+
teamScopeRouting: config.teamScopeRouting === true,
|
|
167
|
+
...(refusals.length > 0 ? { lastRefusals: refusals } : {}),
|
|
168
|
+
note: joined
|
|
169
|
+
? 'join buys presence + being routed to; outbound routing needs no join (initiator face is exempt)'
|
|
170
|
+
: 'NOT joined: no presence/discovery and cannot be routed to; outbound routing still works (initiator face), and joining is a host-side human control (GUI sidebar A2A panel)',
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
function registerTeams() {
|
|
176
|
+
ctx.tools.register(defineTool({
|
|
177
|
+
name: 'a2a_teams',
|
|
178
|
+
description: 'List or search reachable A2A teams: this host\'s own process team and joined session nodes first (marked [this host], '
|
|
179
|
+
+ 'routable over loopback — same-host collaboration needs no peers), then teams across the tracked peer network '
|
|
180
|
+
+ 'with owner label, a recent-activity excerpt, the publishing host (origin: node label + LAN IP — the natural '
|
|
181
|
+
+ 'grouping when a fleet spans machines), and the session\'s working directory when shared. Pass query to filter '
|
|
182
|
+
+ 'by keyword (case-insensitive substring over team name, title, excerpt, origin, or workspace) — searching '
|
|
183
|
+
+ 'discovers one extra referral hop within the call. Canvas teams (<team>/canvas/<name>, user-composed multi-member groups) '
|
|
184
|
+
+ 'list as local rows and as remote rows when a peer publishes them (origin/via identify their owner). Local routing resolves '
|
|
185
|
+
+ 'the first live member or wakes the first cold one; the dispatching session itself is skipped on the local tiers '
|
|
186
|
+
+ '(issue #41: routing to its own group never loops home). Remote candidates still require the existing peer-link and '
|
|
187
|
+
+ 'receiver admission checks; a served-fresh canvasOrg is not itself a signature over membership. '
|
|
188
|
+
+ 'While teamScopeInfo is on (default), the listing follows YOUR collaboration circle: remote canvas rows name a joined session of this host, remote roster rows serve a joined team, and [this host] session rows show only your own row, your canvas teams, and sessions sharing a team with you — an exposed node id invites mis-routing, so the directory does not advertise strangers. The owner\'s GUI panel keeps the full plane; teamScopeInfo: false opens it for tools too. '
|
|
189
|
+
+ 'Call this before a2a_route to pick a target team.',
|
|
190
|
+
parameters: {
|
|
191
|
+
query: { type: 'string', description: 'Optional keyword filter (case-insensitive substring over team/name/description).' },
|
|
192
|
+
},
|
|
193
|
+
output: {
|
|
194
|
+
// gh#198: the host's own verdict on whether this result came from outside this host, persisted on tool/result.
|
|
195
|
+
presentationMeta: peerContentMeta('a2a_teams'),
|
|
196
|
+
schema: {
|
|
197
|
+
type: 'object',
|
|
198
|
+
additionalProperties: false,
|
|
199
|
+
properties: {
|
|
200
|
+
ok: { type: 'boolean', required: true },
|
|
201
|
+
query: { type: 'string', description: 'The filter applied, empty when unfiltered.' },
|
|
202
|
+
teams: {
|
|
203
|
+
type: 'array',
|
|
204
|
+
required: true,
|
|
205
|
+
items: {
|
|
206
|
+
type: 'object',
|
|
207
|
+
additionalProperties: false,
|
|
208
|
+
properties: {
|
|
209
|
+
team: { type: 'string', required: true },
|
|
210
|
+
session: { type: 'string', required: true },
|
|
211
|
+
name: { type: 'string', required: true },
|
|
212
|
+
description: { type: 'string', required: true },
|
|
213
|
+
local: { type: 'boolean', description: 'true when the team is served by this host (loopback candidate).' },
|
|
214
|
+
origin: { type: 'string', description: 'The publishing host (node session label, LAN IP when known) — the natural grouping for fleet rows.' },
|
|
215
|
+
via: { type: 'string', description: 'The peer URL this row was discovered through (host:port), when it came from a peer card.' },
|
|
216
|
+
workspace: { type: 'string', description: 'The session\'s working directory, when shared.' },
|
|
217
|
+
legacy: { type: 'boolean', description: 'true when the publishing node advertises no roster capability (pre-roster peer).' },
|
|
218
|
+
teams: { type: 'array', items: { type: 'string' }, description: 'Roster teams the node declared for this handle (card teamMemberships).' },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
note: { type: 'string', description: 'Present while teamScopeInfo scoping is on: the joined-team scope plus how many rows outside YOUR collaboration circle were withheld (remote/local split; the process team, your own row, and your canvas teams always show).' },
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
render: (args, value) => [{
|
|
226
|
+
type: 'text',
|
|
227
|
+
text: (value.teams.length === 0
|
|
228
|
+
? `No A2A teams${(value.query ?? '') === '' ? '' : ` matching "${String(args.query)}"`} are currently reachable.`
|
|
229
|
+
: value.teams.map(team => `- ${team.team}${team.local === true ? ' [this host]' : ''}${team.name === '' ? '' : ` (${team.name})`}${team.description === '' ? '' : ` — ${team.description}`}${typeof team.origin === 'string' && team.origin !== '' && team.local !== true ? ` @ ${team.origin}` : ''}${typeof team.workspace === 'string' && team.workspace !== '' ? ` [${team.workspace}]` : ''}`).join('\n'))
|
|
230
|
+
+ (value.note === undefined ? '' : `\n${value.note}`),
|
|
231
|
+
}],
|
|
232
|
+
},
|
|
233
|
+
presentCall: args => ({ card: 'generic', title: args.query === undefined || args.query === '' ? 'List reachable A2A teams' : `Search A2A teams: ${args.query}`, kind: 'other', rawInput: args }),
|
|
234
|
+
execute: async (args, exec) => {
|
|
235
|
+
a2aJoinGateRefusal(exec);
|
|
236
|
+
const query = args.query?.trim().toLowerCase() ?? '';
|
|
237
|
+
let teams = await listDirectoryTeams(query !== '', true);
|
|
238
|
+
// S5: remote rows survive only when they serve a joined team (the
|
|
239
|
+
// row's own name or any roster declaration it carries); local rows
|
|
240
|
+
// are host-internal facts and always survive. The note keeps the
|
|
241
|
+
// model from reading a scoped listing as an empty fleet.
|
|
242
|
+
let note;
|
|
243
|
+
if (deps.ownerTeamScopeInfo) {
|
|
244
|
+
const joined = joinedTeamScope();
|
|
245
|
+
const resolveLocal = localHandleResolver();
|
|
246
|
+
const remoteBefore = teams.filter(row => row.local !== true).length;
|
|
247
|
+
teams = teams.filter(row => {
|
|
248
|
+
if (row.local === true)
|
|
249
|
+
return true;
|
|
250
|
+
// A same-zone canvas NAME can exist on two hosts: a local team
|
|
251
|
+
// with that name does not prove membership in the remote one.
|
|
252
|
+
if (row.canvasMembers !== undefined)
|
|
253
|
+
return row.canvasMembers.some(ref => {
|
|
254
|
+
const id = resolveLocal(ref);
|
|
255
|
+
return id !== undefined && joinedSessions.has(id);
|
|
256
|
+
});
|
|
257
|
+
return joined.has(row.team) || (row.teams ?? []).some(name => joined.has(name));
|
|
258
|
+
});
|
|
259
|
+
const hiddenRemote = remoteBefore - teams.filter(row => row.local !== true).length;
|
|
260
|
+
// gh#39 (owner ruling: the listing ADVERTISES targets — an exposed
|
|
261
|
+
// node id invites a session with no collaboration relationship to
|
|
262
|
+
// route at it): [this host] session rows follow the CALLER's
|
|
263
|
+
// collaboration circle. The host's own GUI/panel keeps the full
|
|
264
|
+
// plane (the owner's eyes); the model-tool face is scoped.
|
|
265
|
+
let hiddenLocal = 0;
|
|
266
|
+
const callerAgent = exec.agent;
|
|
267
|
+
if (callerAgent !== undefined) {
|
|
268
|
+
const callerId = String(callerAgent.id);
|
|
269
|
+
const ownHandle = sessionTeamOf(callerAgent);
|
|
270
|
+
const myCanvas = new Set(canvasTeamsOfSession(callerId));
|
|
271
|
+
const myRoster = new Set(teamMemberships.teamsOf(callerId));
|
|
272
|
+
const before = teams.length;
|
|
273
|
+
teams = teams.filter(row => {
|
|
274
|
+
if (row.local !== true)
|
|
275
|
+
return true;
|
|
276
|
+
if (row.team === config.team || row.team === ownHandle)
|
|
277
|
+
return true;
|
|
278
|
+
if (row.team.startsWith(`${config.team}/canvas/`))
|
|
279
|
+
return myCanvas.has(row.name);
|
|
280
|
+
// Another session's team row: visible only when we share a
|
|
281
|
+
// canvas or a declared team with that session.
|
|
282
|
+
const targetId8 = row.team.slice(config.team.length + 1);
|
|
283
|
+
const target = [...sessionNodes.values()].find(agent => id8(String(agent.id)) === targetId8);
|
|
284
|
+
if (target === undefined)
|
|
285
|
+
return true; // unresolvable: keep (host fact)
|
|
286
|
+
const targetId = String(target.id);
|
|
287
|
+
const shares = [...canvasTeamsOfSession(targetId)].some(name => myCanvas.has(name))
|
|
288
|
+
|| [...teamMemberships.teamsOf(targetId)].some(name => myRoster.has(name));
|
|
289
|
+
return shares;
|
|
290
|
+
});
|
|
291
|
+
hiddenLocal = before - teams.length;
|
|
292
|
+
}
|
|
293
|
+
const hidden = hiddenRemote + hiddenLocal;
|
|
294
|
+
note = `teamScopeInfo on: ${String(joined.size)} joined team${joined.size === 1 ? '' : 's'} on this host — ${String(hidden)} row${hidden === 1 ? '' : 's'} outside your collaboration circle withheld (${String(hiddenRemote)} remote, ${String(hiddenLocal)} local [this host] session rows; the process team, your own row, and your canvas teams always show). To widen the scope: a human composes the shared canvas team (the board drag), the owner adds the EXACT team name to teamJoinAllowlist (patterns carry no mint rights, gh#11), or the owner sets teamScopeInfo: false for the open plane`;
|
|
295
|
+
}
|
|
296
|
+
// Canvas members are a private scope hint, not part of the tool schema.
|
|
297
|
+
teams = teams.map(({ canvasMembers: _canvasMembers, ...row }) => row);
|
|
298
|
+
if (query === '')
|
|
299
|
+
return { ok: true, query: '', teams, ...(note !== undefined ? { note } : {}) };
|
|
300
|
+
const matches = (team) => team.team.toLowerCase().includes(query) || team.name.toLowerCase().includes(query) || team.description.toLowerCase().includes(query)
|
|
301
|
+
|| team.session.toLowerCase().includes(query)
|
|
302
|
+
|| (team.origin ?? '').toLowerCase().includes(query) || (team.workspace ?? '').toLowerCase().includes(query) || (team.via ?? '').toLowerCase().includes(query);
|
|
303
|
+
return { ok: true, query, teams: teams.filter(matches), ...(note !== undefined ? { note } : {}) };
|
|
304
|
+
},
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
function registerStatus() {
|
|
308
|
+
ctx.tools.register(defineTool({
|
|
309
|
+
name: 'a2a_status',
|
|
310
|
+
description: 'Read-only A2A network health: the tracked peer fleet with quality scores, routes currently in flight '
|
|
311
|
+
+ '(who is waiting on whom), and the recent routing activity ring (inbound and outbound outcomes). '
|
|
312
|
+
+ 'Includes the idempotency window occupancy (claims, replays, conflicts) behind the UNIQUE-ization '
|
|
313
|
+
+ 'traffic watch. '
|
|
314
|
+
+ 'While teamScopeInfo is on (default), peers / in-flight / activity narrow to the joined-team scope — local traffic and idempotency aggregates stay. '
|
|
315
|
+
+ 'Use it to observe an ongoing collaboration or diagnose delivery instead of re-routing.',
|
|
316
|
+
parameters: {},
|
|
317
|
+
output: {
|
|
318
|
+
// gh#198: the host's own verdict on whether this result came from outside this host, persisted on tool/result.
|
|
319
|
+
presentationMeta: peerContentMeta('a2a_status'),
|
|
320
|
+
schema: {
|
|
321
|
+
type: 'object',
|
|
322
|
+
additionalProperties: false,
|
|
323
|
+
properties: {
|
|
324
|
+
ok: { type: 'boolean', required: true },
|
|
325
|
+
peers: {
|
|
326
|
+
type: 'array',
|
|
327
|
+
required: true,
|
|
328
|
+
items: {
|
|
329
|
+
type: 'object',
|
|
330
|
+
additionalProperties: false,
|
|
331
|
+
properties: {
|
|
332
|
+
url: { type: 'string', required: true },
|
|
333
|
+
score: { type: 'number' },
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
inFlight: {
|
|
338
|
+
type: 'array',
|
|
339
|
+
required: true,
|
|
340
|
+
items: {
|
|
341
|
+
type: 'object',
|
|
342
|
+
additionalProperties: false,
|
|
343
|
+
properties: {
|
|
344
|
+
team: { type: 'string', required: true },
|
|
345
|
+
peer: { type: 'string', required: true },
|
|
346
|
+
startedAt: { type: 'number', required: true },
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
activity: {
|
|
351
|
+
type: 'array',
|
|
352
|
+
required: true,
|
|
353
|
+
items: {
|
|
354
|
+
type: 'object',
|
|
355
|
+
additionalProperties: false,
|
|
356
|
+
properties: {
|
|
357
|
+
ts: { type: 'number', required: true },
|
|
358
|
+
dir: { type: 'string', required: true },
|
|
359
|
+
team: { type: 'string', required: true },
|
|
360
|
+
peer: { type: 'string', required: true },
|
|
361
|
+
ok: { type: 'boolean', required: true },
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
idempotency: {
|
|
366
|
+
type: 'object',
|
|
367
|
+
additionalProperties: false,
|
|
368
|
+
required: true,
|
|
369
|
+
properties: {
|
|
370
|
+
window: { type: 'number', required: true },
|
|
371
|
+
cap: { type: 'number', required: true },
|
|
372
|
+
pending: { type: 'number', required: true },
|
|
373
|
+
settled: { type: 'number', required: true },
|
|
374
|
+
claimsFresh: { type: 'number', required: true },
|
|
375
|
+
replays: { type: 'number', required: true },
|
|
376
|
+
conflicts: { type: 'number', required: true },
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
render: (_args, value) => [{
|
|
382
|
+
type: 'text',
|
|
383
|
+
text: [
|
|
384
|
+
...(value.inFlight.length === 0 ? [] : ['In flight:']),
|
|
385
|
+
...value.inFlight.map((route) => ` → ${route.team} via ${route.peer === 'local' ? 'this host' : route.peer}`),
|
|
386
|
+
`Peers (${String(value.peers.length)}):`,
|
|
387
|
+
...value.peers.map((peer) => ` ${peer.url}${typeof peer.score === 'number' ? ` (score ${String(Math.round(peer.score))})` : ''}`),
|
|
388
|
+
`Idempotency window: ${String(value.idempotency.window)}/${String(value.idempotency.cap)} (pending ${String(value.idempotency.pending)}, settled ${String(value.idempotency.settled)}) — claims ${String(value.idempotency.claimsFresh)}, replays ${String(value.idempotency.replays)}, conflicts ${String(value.idempotency.conflicts)}`,
|
|
389
|
+
...(value.activity.length === 0 ? [] : ['Recent routes:']),
|
|
390
|
+
...[...value.activity].reverse().slice(0, 10).map((entry) => ` ${entry.dir === 'in' ? '←' : '→'} ${entry.team} ${entry.ok ? 'ok' : 'failed'}`),
|
|
391
|
+
].join('\n'),
|
|
392
|
+
}],
|
|
393
|
+
},
|
|
394
|
+
presentCall: () => ({ card: 'generic', title: 'A2A network status', kind: 'other', rawInput: null }),
|
|
395
|
+
execute: async (_args, exec) => {
|
|
396
|
+
a2aJoinGateRefusal(exec);
|
|
397
|
+
// S5 open plane: the unscoped snapshot, as before.
|
|
398
|
+
if (!config.teamScopeInfo) {
|
|
399
|
+
return {
|
|
400
|
+
ok: true,
|
|
401
|
+
peers: peerStore.list().map(url => ({ url, score: peerStore.score(url) })),
|
|
402
|
+
inFlight: [...inFlightRoutes.values()].map(route => ({ team: route.team, peer: route.peer, startedAt: route.startedAt })),
|
|
403
|
+
activity: recentActivity.slice(),
|
|
404
|
+
idempotency: idempotencyStore.stats(),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
// S5 scoped plane: the tracked fleet narrows to peers whose verified
|
|
408
|
+
// card serves a joined team (bounded-concurrency memoized fetch — a
|
|
409
|
+
// warm sweep costs no extra round trips); routing facts keep
|
|
410
|
+
// host-internal traffic (peer 'local') and drop remote rows whose
|
|
411
|
+
// team is outside the scope, so cross-fleet collaborations stay
|
|
412
|
+
// unread here. The idempotency aggregate is host machinery, not
|
|
413
|
+
// attribution — it stays unscoped like the ledger it serves.
|
|
414
|
+
const joined = joinedTeamScope();
|
|
415
|
+
const resolveLocal = localHandleResolver();
|
|
416
|
+
const visibleTeams = new Set(joined);
|
|
417
|
+
for (const agent of sessionNodes.values()) {
|
|
418
|
+
if (teamMemberships.teamsOf(String(agent.id)).some(team => joined.has(team)))
|
|
419
|
+
visibleTeams.add(sessionTeamOf(agent));
|
|
420
|
+
}
|
|
421
|
+
// The host's own process-team face is host-internal ALWAYS — a
|
|
422
|
+
// zero-roster host must still see inbound deliveries and outbound
|
|
423
|
+
// attempts addressed to itself (review M2: gating this on
|
|
424
|
+
// joined.size > 0 self-blinded exactly that traffic).
|
|
425
|
+
visibleTeams.add(config.team);
|
|
426
|
+
// G2 (review artifact 688c0354d3 v2): the host-synthetic face rows —
|
|
427
|
+
// the /a2a/log telemetry records under the synthetic team label
|
|
428
|
+
// 'log' (recordActivity('in', 'log', caller) on both admission
|
|
429
|
+
// outcomes) — are THIS host's own face facts, not fleet observation.
|
|
430
|
+
// Scoping filtered them like foreign traffic and hid the host's own
|
|
431
|
+
// inbound log-face telemetry from its own supervisor; they pass.
|
|
432
|
+
visibleTeams.add('log');
|
|
433
|
+
const fetch = memoizedCardFetch();
|
|
434
|
+
const peerRows = await mapBounded(peerStore.list(), SWEEP_CONCURRENCY, async (url) => {
|
|
435
|
+
const card = await fetch(url);
|
|
436
|
+
return card !== undefined && cardServesJoined(card, joined, resolveLocal) ? { url, score: peerStore.score(url) } : undefined;
|
|
437
|
+
});
|
|
438
|
+
const peers = peerRows.filter((row) => row !== undefined);
|
|
439
|
+
const inScope = (entry) => entry.peer === 'local' || visibleTeams.has(entry.team);
|
|
440
|
+
return {
|
|
441
|
+
ok: true,
|
|
442
|
+
peers,
|
|
443
|
+
inFlight: [...inFlightRoutes.values()].map(route => ({ team: route.team, peer: route.peer, startedAt: route.startedAt })).filter(inScope),
|
|
444
|
+
activity: recentActivity.filter(inScope),
|
|
445
|
+
idempotency: idempotencyStore.stats(),
|
|
446
|
+
};
|
|
447
|
+
},
|
|
448
|
+
}));
|
|
449
|
+
}
|
|
450
|
+
function registerTasks() {
|
|
451
|
+
ctx.tools.register(defineTool({
|
|
452
|
+
name: 'a2a_tasks',
|
|
453
|
+
description: 'Read-only three-tier ledger of routed A2A tasks: pending rows still owed a receipt, rows '
|
|
454
|
+
+ 'auto-dead-lettered past the stale TTL, and the bounded archive of correlated '
|
|
455
|
+
+ '`[A2A receipt] task <task_id>` outcomes. Use it to reconcile dispatched async work instead of '
|
|
456
|
+
+ 're-routing to ask for progress.',
|
|
457
|
+
parameters: {},
|
|
458
|
+
output: {
|
|
459
|
+
// gh#198: the host's own verdict on whether this result came from outside this host, persisted on tool/result.
|
|
460
|
+
presentationMeta: peerContentMeta('a2a_tasks'),
|
|
461
|
+
schema: {
|
|
462
|
+
type: 'object',
|
|
463
|
+
additionalProperties: false,
|
|
464
|
+
properties: {
|
|
465
|
+
ok: { type: 'boolean', required: true },
|
|
466
|
+
tasks: {
|
|
467
|
+
type: 'array',
|
|
468
|
+
required: true,
|
|
469
|
+
items: {
|
|
470
|
+
type: 'object',
|
|
471
|
+
additionalProperties: false,
|
|
472
|
+
properties: {
|
|
473
|
+
taskId: { type: 'string', required: true },
|
|
474
|
+
team: { type: 'string', required: true },
|
|
475
|
+
peer: { type: 'string', required: true },
|
|
476
|
+
startedAt: { type: 'number', required: true },
|
|
477
|
+
contextId: { type: 'string' },
|
|
478
|
+
brief: { type: 'string' },
|
|
479
|
+
status: { type: 'string', required: true },
|
|
480
|
+
deadAt: { type: 'number' },
|
|
481
|
+
direction: { type: 'string', enum: ['inbound', 'outbound'] },
|
|
482
|
+
receiptExpected: { type: 'boolean' },
|
|
483
|
+
reason: { type: 'string', enum: ['fire-forget', 'unconsumed'] },
|
|
484
|
+
sender: {
|
|
485
|
+
type: 'object',
|
|
486
|
+
additionalProperties: false,
|
|
487
|
+
properties: {
|
|
488
|
+
kind: { type: 'string', required: true, enum: ['session', 'host', 'bridge'] },
|
|
489
|
+
id: { type: 'string', required: true },
|
|
490
|
+
face: { type: 'string' },
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
archive: {
|
|
497
|
+
type: 'array',
|
|
498
|
+
required: true,
|
|
499
|
+
items: {
|
|
500
|
+
type: 'object',
|
|
501
|
+
additionalProperties: false,
|
|
502
|
+
properties: {
|
|
503
|
+
taskId: { type: 'string', required: true },
|
|
504
|
+
team: { type: 'string', required: true },
|
|
505
|
+
startedAt: { type: 'number', required: true },
|
|
506
|
+
resolvedAt: { type: 'number', required: true },
|
|
507
|
+
summary: { type: 'string' },
|
|
508
|
+
brief: { type: 'string' },
|
|
509
|
+
sender: {
|
|
510
|
+
type: 'object',
|
|
511
|
+
additionalProperties: false,
|
|
512
|
+
properties: {
|
|
513
|
+
kind: { type: 'string', required: true, enum: ['session', 'host', 'bridge'] },
|
|
514
|
+
id: { type: 'string', required: true },
|
|
515
|
+
face: { type: 'string' },
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
archivedTotal: { type: 'number', required: true },
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
render: (_args, value) => [{
|
|
525
|
+
type: 'text',
|
|
526
|
+
text: value.tasks.length === 0 && value.archivedTotal === 0
|
|
527
|
+
? 'No routed tasks are owed a receipt.'
|
|
528
|
+
: [
|
|
529
|
+
...(() => {
|
|
530
|
+
// C-ruling split (2026-09-01 decision seat): only young,
|
|
531
|
+
// receipt-expected pending rows are settleable debt — the
|
|
532
|
+
// stale tail is conversational noise waiting on the dead
|
|
533
|
+
// TTL; count it, don't list it.
|
|
534
|
+
const pending = value.tasks.filter((task) => task.status === 'pending');
|
|
535
|
+
const settleable = pending.filter((task) => task.receiptExpected !== false && Date.now() - task.startedAt < 60 * 60_000);
|
|
536
|
+
const staleCount = pending.length - settleable.length;
|
|
537
|
+
return [
|
|
538
|
+
...(settleable.length > 0 ? ['Owed receipts:'] : []),
|
|
539
|
+
...settleable.map((task) => {
|
|
540
|
+
const context = task.contextId === undefined || task.contextId === '' ? '' : `, follow-up context ${task.contextId}`;
|
|
541
|
+
const brief = task.brief === undefined || task.brief === '' ? '' : ` — "${task.brief}"`;
|
|
542
|
+
return ` - ${task.taskId}${brief} → ${task.team} (via ${task.peer === 'local' ? 'this host' : task.peer}), waiting ${describeAge(Date.now() - task.startedAt)}${context}`;
|
|
543
|
+
}),
|
|
544
|
+
...(staleCount > 0 ? [`Delivered conversationally without a formal receipt (${String(staleCount)}) — not debts; they auto-dead-letter at the stale TTL.`] : []),
|
|
545
|
+
];
|
|
546
|
+
})(),
|
|
547
|
+
...(value.tasks.some((task) => task.status === 'dead') ? ['Dead-lettered (auto-flagged past the stale TTL; a revived target can still settle with a late receipt):'] : []),
|
|
548
|
+
...value.tasks.filter((task) => task.status === 'dead').map((task) => {
|
|
549
|
+
const context = task.contextId === undefined || task.contextId === '' ? '' : `, follow-up context ${task.contextId}`;
|
|
550
|
+
const brief = task.brief === undefined || task.brief === '' ? '' : ` — "${task.brief}"`;
|
|
551
|
+
return ` - ${task.taskId}${brief} → ${task.team} (via ${task.peer === 'local' ? 'this host' : task.peer}), dispatched ${describeAge(Date.now() - task.startedAt)} ago${context}`;
|
|
552
|
+
}),
|
|
553
|
+
...(value.archivedTotal > 0 ? [`Archived (${String(value.archivedTotal)}), most recent first:`] : []),
|
|
554
|
+
...value.archive.map((entry) => {
|
|
555
|
+
const brief = entry.brief === undefined || entry.brief === '' ? '' : ` — "${entry.brief}"`;
|
|
556
|
+
return ` - ${entry.taskId}${brief} → ${entry.team}: after ${describeAge(entry.resolvedAt - entry.startedAt)}, ${entry.summary === undefined || entry.summary === '' ? 'resolved' : entry.summary}`;
|
|
557
|
+
}),
|
|
558
|
+
...(value.archivedTotal > value.archive.length ? [` (+${String(value.archivedTotal - value.archive.length)} older not shown)`] : []),
|
|
559
|
+
].join('\n'),
|
|
560
|
+
}],
|
|
561
|
+
},
|
|
562
|
+
presentCall: () => ({ card: 'generic', title: 'A2A task ledger', kind: 'other', rawInput: null }),
|
|
563
|
+
execute: async (_args, exec) => {
|
|
564
|
+
a2aJoinGateRefusal(exec);
|
|
565
|
+
observeAuthorization('lazy');
|
|
566
|
+
const archiveAll = taskLedger.archive();
|
|
567
|
+
return {
|
|
568
|
+
ok: true,
|
|
569
|
+
tasks: taskLedger.list().map(task => ({ ...task })),
|
|
570
|
+
archive: archiveAll.slice(0, 5).map(({ taskId, team, startedAt, resolvedAt, summary, brief, sender }) => ({ taskId, team, startedAt, resolvedAt, ...(summary !== undefined && summary !== '' ? { summary } : {}), ...(brief !== undefined ? { brief } : {}), ...(sender !== undefined ? { sender } : {}) })),
|
|
571
|
+
archivedTotal: archiveAll.length,
|
|
572
|
+
};
|
|
573
|
+
},
|
|
574
|
+
}));
|
|
575
|
+
}
|
|
576
|
+
function registerProbe() {
|
|
577
|
+
ctx.tools.register(defineTool({
|
|
578
|
+
name: 'a2a_probe',
|
|
579
|
+
description: 'Probe the tracked peer fleet for reachability and round-trip latency: one verified-card fetch per peer '
|
|
580
|
+
+ '(the same fetch discovery uses), reporting reachable peers with their team and latency and unreachable '
|
|
581
|
+
+ 'ones with the failure reason. Pass url to probe one target instead of the whole fleet. '
|
|
582
|
+
+ 'While teamScopeInfo is on (default), the sweep covers only peers serving a joined team, and a single URL is refused unless its verified card proves service. '
|
|
583
|
+
+ 'Use it before dispatching verification work or when routes fail over, instead of guessing which node is down.',
|
|
584
|
+
parameters: {
|
|
585
|
+
url: { type: 'string', description: 'Optional single peer base URL to probe; empty probes every tracked peer.' },
|
|
586
|
+
},
|
|
587
|
+
output: {
|
|
588
|
+
// gh#198: the host's own verdict on whether this result came from outside this host, persisted on tool/result.
|
|
589
|
+
presentationMeta: peerContentMeta('a2a_probe'),
|
|
590
|
+
schema: {
|
|
591
|
+
type: 'object',
|
|
592
|
+
additionalProperties: false,
|
|
593
|
+
properties: {
|
|
594
|
+
ok: { type: 'boolean', required: true },
|
|
595
|
+
results: {
|
|
596
|
+
type: 'array',
|
|
597
|
+
required: true,
|
|
598
|
+
items: {
|
|
599
|
+
type: 'object',
|
|
600
|
+
additionalProperties: false,
|
|
601
|
+
properties: {
|
|
602
|
+
url: { type: 'string', required: true },
|
|
603
|
+
reachable: { type: 'boolean', required: true },
|
|
604
|
+
ms: { type: 'number', description: 'Round-trip of the card fetch, reachable or not.' },
|
|
605
|
+
team: { type: 'string', description: 'The team the peer publishes, when reachable.' },
|
|
606
|
+
error: { type: 'string', description: 'Why the peer is unreachable.' },
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
render: (_args, value) => [{
|
|
613
|
+
type: 'text',
|
|
614
|
+
text: value.results.length === 0
|
|
615
|
+
? 'No peers are tracked; add seeds or let referrals arrive.'
|
|
616
|
+
: [
|
|
617
|
+
`Fleet probe (${String(value.results.length)}): ${String(value.results.filter((row) => row.reachable).length)} reachable, ${String(value.results.filter((row) => !row.reachable).length)} down`,
|
|
618
|
+
...value.results.map((row) => ` ${row.reachable ? '✓' : '✗'} ${row.url}${row.reachable ? ` (team ${String(row.team)}, ${String(row.ms)}ms)` : ` (${String(row.error)})`}`),
|
|
619
|
+
].join('\n'),
|
|
620
|
+
}],
|
|
621
|
+
},
|
|
622
|
+
presentCall: args => ({ card: 'generic', title: args.url === undefined || args.url === '' ? 'Probe the A2A fleet' : `Probe A2A peer: ${args.url}`, kind: 'other', rawInput: args }),
|
|
623
|
+
execute: async (args, exec) => {
|
|
624
|
+
a2aJoinGateRefusal(exec);
|
|
625
|
+
const probeOne = async (url) => {
|
|
626
|
+
const startedAt = Date.now();
|
|
627
|
+
const outcome = await client.fetchCardDetail(url);
|
|
628
|
+
const ms = Date.now() - startedAt;
|
|
629
|
+
// The fetch is the probe: settling it moves the peer's quality score
|
|
630
|
+
// and learns its referrals, exactly like a discovery sweep would,
|
|
631
|
+
// while the outcome's stage names the failure for the report.
|
|
632
|
+
const card = settlePeerCard(url, outcome);
|
|
633
|
+
if (card !== undefined) {
|
|
634
|
+
// gh#71 fix ①: a successful full-face fetch persists into the warm
|
|
635
|
+
// cache — the probe is often the FIRST contact (no directory sweep
|
|
636
|
+
// has run), and the state face's peerOrg reads exactly this cache.
|
|
637
|
+
cardCache.set(url, { at: Date.now(), card });
|
|
638
|
+
return { url, reachable: true, ms, team: card.team };
|
|
639
|
+
}
|
|
640
|
+
if (outcome.ok)
|
|
641
|
+
return { url, reachable: false, ms, error: 'dropped: the card lists this node back as its own referral' };
|
|
642
|
+
return outcome.stage === 'unreachable'
|
|
643
|
+
? { url, reachable: false, ms, error: `unreachable: ${outcome.detail}` }
|
|
644
|
+
: { url, reachable: false, ms, error: `rejected: ${outcome.reason} card` };
|
|
645
|
+
};
|
|
646
|
+
// S5: a reachability report is fleet disclosure too. The sweep probes
|
|
647
|
+
// only peers whose verified card serves a joined team (memoized —
|
|
648
|
+
// warm cards cost nothing); a single-URL probe needs proof of service
|
|
649
|
+
// — a FRESH card, or a stale cached one: a peer that once served a
|
|
650
|
+
// joined team and is now down gets the normal down report (that
|
|
651
|
+
// reachability fact belongs to the team), while a URL that never
|
|
652
|
+
// proved service is refused without a probe (its reachability is
|
|
653
|
+
// exactly the fact scoping withholds).
|
|
654
|
+
const joined = joinedTeamScope();
|
|
655
|
+
const resolveLocal = localHandleResolver();
|
|
656
|
+
const fetch = memoizedCardFetch();
|
|
657
|
+
const serves = async (url) => {
|
|
658
|
+
const card = await fetch(url);
|
|
659
|
+
return card !== undefined && cardServesJoined(card, joined, resolveLocal);
|
|
660
|
+
};
|
|
661
|
+
if (args.url !== undefined && args.url !== '') {
|
|
662
|
+
if (config.teamScopeInfo && !(await serves(args.url))) {
|
|
663
|
+
const stale = cardCache.get(args.url)?.card;
|
|
664
|
+
if (stale !== undefined && cardServesJoined(stale, joined, resolveLocal)) {
|
|
665
|
+
// Was serving, now unverifiable fresh — probe and report the
|
|
666
|
+
// down state through the normal path.
|
|
667
|
+
return { ok: true, results: await Promise.all([args.url].map(probeOne)) };
|
|
668
|
+
}
|
|
669
|
+
return { ok: true, results: [{ url: args.url, reachable: false, ms: 0, error: stale === undefined
|
|
670
|
+
? 'refused (not probed): teamScopeInfo on — no verified card for this URL proves service to a joined team'
|
|
671
|
+
: 'refused (not probed): teamScopeInfo on — this URL\'s verified card shows no team joined by this host\'s nodes' }] };
|
|
672
|
+
}
|
|
673
|
+
return { ok: true, results: await Promise.all([args.url].map(probeOne)) };
|
|
674
|
+
}
|
|
675
|
+
const targets = config.teamScopeInfo
|
|
676
|
+
? await mapBounded(peerStore.list(), SWEEP_CONCURRENCY, async (url) => await serves(url) ? url : undefined).then(rows => rows.filter((url) => url !== undefined))
|
|
677
|
+
: peerStore.list();
|
|
678
|
+
// Promise.all keeps the report in the store's preference order.
|
|
679
|
+
return { ok: true, results: await Promise.all(targets.map(probeOne)) };
|
|
680
|
+
},
|
|
681
|
+
}));
|
|
682
|
+
}
|
|
683
|
+
return { registerWhoami, registerTeams, registerStatus, registerTasks, registerProbe };
|
|
684
|
+
}
|