@nelsonlongxiang/dsh-open-a2a-net 0.6.28 → 0.6.30
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 +87 -14
- package/README_zh.md +83 -7
- package/assets/nexusDist/assets/index-C_x3iAth.js +9 -0
- package/assets/nexusDist/assets/index-Dz8N80tI.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +0 -0
- package/cordis.patch.yml +21 -21
- package/lib/canvas-store.js +21 -1
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +755 -0
- package/lib/faces/canvas-notify.js +138 -0
- package/lib/faces/wire.js +669 -0
- package/lib/flood-guard.js +106 -0
- package/lib/index.js +200 -1392
- package/lib/recursion-guard.js +78 -0
- package/lib/runtime.js +1 -0
- package/lib/skill/SKILL.md +35 -9
- package/lib/skill/references/cli.md +24 -7
- package/lib/skill/references/collaboration.md +355 -0
- package/lib/skill/references/discovery.md +5 -2
- package/lib/skill/references/pitfalls.md +2 -1
- package/lib/skill/references/routing.md +18 -8
- package/lib/skill/references/setup.md +152 -0
- package/lib/task-ledger.js +40 -4
- package/lib/types/canvas-store.d.ts +10 -1
- package/lib/types/faces/admin.d.ts +133 -0
- package/lib/types/faces/canvas-notify.d.ts +74 -0
- package/lib/types/faces/wire.d.ts +171 -0
- package/lib/types/flood-guard.d.ts +29 -0
- package/lib/types/index.d.ts +11 -3
- package/lib/types/recursion-guard.d.ts +56 -0
- package/lib/types/runtime.d.ts +35 -0
- package/lib/types/task-ledger.d.ts +15 -1
- package/nexus-stage/package-lock.json +2262 -2262
- package/nexus-stage/src/canvas-ops.ts +175 -175
- package/nexus-stage/src/canvas-wire.ts +156 -156
- package/nexus-stage/src/edges.ts +72 -72
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +240 -240
- package/nexus-stage/src/layout-doc.ts +143 -143
- package/nexus-stage/src/layout-wire.ts +158 -158
- package/nexus-stage/src/main.ts +5 -1
- package/nexus-stage/src/planning-card-text.ts +73 -0
- package/nexus-stage/src/planning-edges.ts +26 -0
- package/nexus-stage/src/planning-fit.ts +45 -0
- package/nexus-stage/src/planning-legend.ts +38 -0
- package/nexus-stage/src/planning-mirror.ts +31 -0
- package/nexus-stage/src/planning-overlap.ts +159 -0
- package/nexus-stage/src/planning-relayout.ts +121 -0
- package/nexus-stage/src/planning-search.ts +168 -0
- package/nexus-stage/src/planning-types.ts +1 -0
- package/nexus-stage/src/planning-undo.ts +68 -0
- package/nexus-stage/src/planning-view.ts +120 -121
- package/nexus-stage/src/planning.css +41 -8
- package/nexus-stage/src/seat.ts +35 -35
- package/nexus-stage/src/stage-name.ts +13 -13
- package/nexus-stage/src/tokens.ts +31 -31
- package/nexus-stage/src/viewport.ts +24 -1
- package/nexus-stage/src/wb-chat.ts +20 -6
- package/nexus-stage/src/wb-ledger.ts +20 -7
- package/nexus-stage/src/wb-names.ts +43 -0
- package/nexus-stage/src/workbench.css +8 -2
- package/nexus-stage/src/workbench.ts +55 -17
- package/nexus-stage/src/world.ts +13 -21
- package/package.json +216 -214
- package/assets/nexusDist/assets/index-1niOBYxp.js +0 -5
- package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
- package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
package/lib/index.js
CHANGED
|
@@ -28,22 +28,20 @@ export function resolveCallerLabel(input) {
|
|
|
28
28
|
}
|
|
29
29
|
import { signCard, verifyCard } from "./card.js";
|
|
30
30
|
import { GroupStore } from "./group-store.js";
|
|
31
|
-
import { CanvasStore, isRemoteMemberRef
|
|
31
|
+
import { CanvasStore, isRemoteMemberRef } from "./canvas-store.js";
|
|
32
32
|
import { decodeSessionLog, extractTranscript, TRANSCRIPT_DEFAULT_LIMIT, TRANSCRIPT_MAX_FILE_BYTES, TRANSCRIPT_MAX_LIMIT } from "./session-transcript.js";
|
|
33
33
|
import { LayoutStore } from "./layout-store.js";
|
|
34
|
-
import { fileURLToPath } from 'node:url';
|
|
35
34
|
import { JoinedSessions } from "./joined-store.js";
|
|
36
35
|
import { ExposureStore } from "./exposure-store.js";
|
|
37
|
-
import { applyOwnerSettings } from "./owner-settings.js";
|
|
38
36
|
import { CliDepositStore } from "./cli-deposit.js";
|
|
39
37
|
import { TeamMembershipStore } from "./team-store.js";
|
|
40
38
|
import { PeerStore } from "./peer-store.js";
|
|
41
39
|
import { SelfReferralFilter } from "./self-suppress.js";
|
|
42
|
-
import {
|
|
43
|
-
import { TaskLedger, TASK_STALE_TTL_MS, SUMMARY_CAP, ARCHIVE_CAP, archivedEndedBy, briefOf } from "./task-ledger.js";
|
|
40
|
+
import { TaskLedger, TASK_STALE_TTL_MS, SUMMARY_CAP, briefOf } from "./task-ledger.js";
|
|
44
41
|
import { formatReceipt, parseReceipt } from "./receipt.js";
|
|
45
42
|
import { runReceiptLadder } from "./receipt-ladder.js";
|
|
46
43
|
import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
|
|
44
|
+
import { createFloodGuard } from "./flood-guard.js";
|
|
47
45
|
import { resolveZone } from "./zone.js";
|
|
48
46
|
import { registerA2aNetworkSkill } from "./skill-registration.js";
|
|
49
47
|
import s from '@deepseek-ai/schemastery';
|
|
@@ -51,7 +49,9 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
|
51
49
|
import { MessageId } from '@deepseek-ai/dsh-llm';
|
|
52
50
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
53
51
|
import { A2aClient } from "./a2a-client.js";
|
|
54
|
-
import {
|
|
52
|
+
import { WIRE_ERROR_PAYLOAD_TOO_LARGE } from "./transport-caps.js";
|
|
53
|
+
import { registerAdminFaces } from "./faces/admin.js";
|
|
54
|
+
import { registerWireFaces } from "./faces/wire.js";
|
|
55
55
|
import { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
|
|
56
56
|
export { A2aClient } from "./a2a-client.js";
|
|
57
57
|
export { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
|
|
@@ -79,11 +79,13 @@ export const Config = s.object({
|
|
|
79
79
|
wakeReconcileBackoffBaseMs: s.number().default(5_000),
|
|
80
80
|
wakeReconcileMaxBackoffMs: s.number().default(600_000),
|
|
81
81
|
/**
|
|
82
|
-
* S4: the joinable-team curation list. A model calling
|
|
83
|
-
* only declare membership in a team
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* a
|
|
82
|
+
* S4 (gh#11 N7): the joinable-team curation list. A model calling
|
|
83
|
+
* a2a_team_join may only declare membership in a team with an EXACT
|
|
84
|
+
* entry here — trailing-`*` patterns are readable but authorize nothing
|
|
85
|
+
* (no mint rights: a pattern pre-authorizing every future name under it
|
|
86
|
+
* would let a model mint teams nobody created). Default empty: the
|
|
87
|
+
* owner's no-self-join ruling, institutionalized as default-deny; a
|
|
88
|
+
* refused join carries guidance instead of failing silently.
|
|
87
89
|
*/
|
|
88
90
|
teamJoinAllowlist: s.array(s.string()).default([]),
|
|
89
91
|
/**
|
|
@@ -365,10 +367,10 @@ export function apply(ctx, config) {
|
|
|
365
367
|
* @param res - the response for the malformed-body rejection.
|
|
366
368
|
* @param use - receives the parsed body.
|
|
367
369
|
*/
|
|
368
|
-
// Wire code for boundary-refused corrupted text
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
const WIRE_ERROR_TEXT_CORRUPTED = -
|
|
370
|
+
// Wire code for boundary-refused corrupted text (cluster-C error-code
|
|
371
|
+
// registry): -32004 belongs to the route verdict, -32005 to the client's
|
|
372
|
+
// DELIVERED-UNSETTLED family, so the corrupted-text guard owns -32006.
|
|
373
|
+
const WIRE_ERROR_TEXT_CORRUPTED = -32006;
|
|
372
374
|
/**
|
|
373
375
|
* Boundary guard for undecodable text (consumed by the readJsonBody
|
|
374
376
|
* guard): U+FFFD is produced by a lossy decoder upstream of us — the bytes
|
|
@@ -586,8 +588,18 @@ export function apply(ctx, config) {
|
|
|
586
588
|
if (teamAuditRing.length > 32)
|
|
587
589
|
teamAuditRing.splice(0, teamAuditRing.length - 32);
|
|
588
590
|
};
|
|
591
|
+
/**
|
|
592
|
+
* Recent routing activity for the network panel: a bounded ring of the
|
|
593
|
+
* latest inbound and outbound route outcomes, newest last. The state route
|
|
594
|
+
* serves a copy; the panel derives unread badges from new entries.
|
|
595
|
+
*/
|
|
589
596
|
const ACTIVITY_CAP = 20;
|
|
590
597
|
const recentActivity = [];
|
|
598
|
+
/**
|
|
599
|
+
* In-flight outbound routes: a route registers on dial and unregisters on
|
|
600
|
+
* settlement, so the panel can show collaboration in progress ("who is
|
|
601
|
+
* waiting on whom") — the state route serves a snapshot.
|
|
602
|
+
*/
|
|
591
603
|
const inFlightRoutes = new Map();
|
|
592
604
|
let inFlightSeq = 0;
|
|
593
605
|
const beginRoute = (team, peer) => {
|
|
@@ -695,6 +707,7 @@ export function apply(ctx, config) {
|
|
|
695
707
|
// executes at most once inside the 24h window — same-key replays answer
|
|
696
708
|
// refused-but-idempotent, same-key different-payload answers conflict.
|
|
697
709
|
const idempotencyStore = new IdempotencyStore(join(home, 'a2a', 'idempotency.json'));
|
|
710
|
+
const refuseIfFlooded = createFloodGuard(idempotencyStore, recordActivity);
|
|
698
711
|
// Unmasked-identity suppression: gossip mirrors peer lists back and forth,
|
|
699
712
|
// so a URL whose fetch just proved it serves this node — or an already
|
|
700
713
|
// tracked node (an alias spelling) — keeps coming home as an inbound
|
|
@@ -1413,715 +1426,21 @@ export function apply(ctx, config) {
|
|
|
1413
1426
|
});
|
|
1414
1427
|
};
|
|
1415
1428
|
whenWebServerSettled((webServer) => {
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
return name === undefined ? undefined : name;
|
|
1432
|
-
};
|
|
1433
|
-
// Archived sessions are closed: the GUI hides them everywhere, so
|
|
1434
|
-
// the panel must not re-list them as join surface rows — joined
|
|
1435
|
-
// or not. (The prune above already took their join intents; this
|
|
1436
|
-
// covers never-joined live roots and any intent a restart left.)
|
|
1437
|
-
const isArchived = archivedSessionFilter();
|
|
1438
|
-
const tFacts0 = Date.now();
|
|
1439
|
-
const sessions = [...liveRoots.values()]
|
|
1440
|
-
.filter(agent => isArchived?.(String(agent.id)) !== true)
|
|
1441
|
-
.map(agent => ({
|
|
1442
|
-
id: String(agent.id),
|
|
1443
|
-
label: sessionLabelOf(agent),
|
|
1444
|
-
team: sessionTeamOf(agent),
|
|
1445
|
-
...nodeMetadataOf(agent),
|
|
1446
|
-
joined: sessionNodes.has(String(agent.id)),
|
|
1447
|
-
live: true,
|
|
1448
|
-
teams: [...teamMemberships.teamsOf(String(agent.id))],
|
|
1449
|
-
// #19 D3: the session's exposure tier (linked floor /
|
|
1450
|
-
// fleet opt-up) — readable on the state face.
|
|
1451
|
-
exposure: exposureStore.tierOf(String(agent.id)),
|
|
1452
|
-
...(canvasStore.teamsOf(String(agent.id)).length > 0 ? { canvasTeams: [...canvasStore.teamsOf(String(agent.id))] } : {}),
|
|
1453
|
-
...(groupOf(String(agent.id)) !== undefined ? { group: groupOf(String(agent.id)) } : {}),
|
|
1454
|
-
}));
|
|
1455
|
-
// Cold joined sessions: a remembered intent whose Agent is not
|
|
1456
|
-
// back yet (host restarted, session not opened). The row carries
|
|
1457
|
-
// no facts — the Web client merges the session title from its
|
|
1458
|
-
// own list — and wakes through the normal session-open path,
|
|
1459
|
-
// after which the remount above joins it automatically.
|
|
1460
|
-
// Cold rows only exist when a joined intent lacks a live agent;
|
|
1461
|
-
// with all intents live (or none) the persistence layer is never
|
|
1462
|
-
// asked and the handler stays synchronous to its response.
|
|
1463
|
-
const tFacts1 = Date.now();
|
|
1464
|
-
const coldCandidates = joinedSessions.list().filter(id => !liveRoots.has(id));
|
|
1465
|
-
const persistence = ctx.get('sessionPersistence');
|
|
1466
|
-
const persisted = persistence !== undefined && coldCandidates.length > 0 ? await coldJoinedIds() : new Set();
|
|
1467
|
-
const tCold = Date.now();
|
|
1468
|
-
for (const id of coldCandidates) {
|
|
1469
|
-
// Archived intents are retained on disk (an archived window is
|
|
1470
|
-
// transient; the 3081 wake defect came from deleting them), so
|
|
1471
|
-
// the join surface must skip them here instead.
|
|
1472
|
-
if (!persisted.has(id) || isArchived?.(id) === true)
|
|
1473
|
-
continue;
|
|
1474
|
-
sessions.push({
|
|
1475
|
-
id,
|
|
1476
|
-
label: `${session}-${id8(id)}`,
|
|
1477
|
-
team: `${config.team}/${id8(id)}`,
|
|
1478
|
-
joined: true,
|
|
1479
|
-
live: false,
|
|
1480
|
-
teams: [...teamMemberships.teamsOf(id)],
|
|
1481
|
-
...(canvasStore.teamsOf(id).length > 0 ? { canvasTeams: [...canvasStore.teamsOf(id)] } : {}),
|
|
1482
|
-
...(groupOf(id) !== undefined ? { group: groupOf(id) } : {}),
|
|
1483
|
-
});
|
|
1484
|
-
}
|
|
1485
|
-
const body = JSON.stringify({
|
|
1486
|
-
nodes: true,
|
|
1487
|
-
version: PLUGIN_VERSION,
|
|
1488
|
-
sessions,
|
|
1489
|
-
groups: groupStore.list(),
|
|
1490
|
-
canvas: {
|
|
1491
|
-
teams: canvasStore.list().map(name => ({
|
|
1492
|
-
name,
|
|
1493
|
-
team: canvasTeamOf(name),
|
|
1494
|
-
members: canvasStore.membersOf(name).map(id => {
|
|
1495
|
-
// Dual shape: local members report this zone's join/live
|
|
1496
|
-
// facts; remote refs are opaque team handles — the state
|
|
1497
|
-
// face passes them through with remote:true for the
|
|
1498
|
-
// workbench's member chips and pickers.
|
|
1499
|
-
if (isRemoteMemberRef(id)) {
|
|
1500
|
-
return { id, team: id, remote: true };
|
|
1501
|
-
}
|
|
1502
|
-
return {
|
|
1503
|
-
id,
|
|
1504
|
-
team: `${config.team}/${id8(id)}`,
|
|
1505
|
-
joined: sessionNodes.has(id) || joinedSessions.has(id),
|
|
1506
|
-
live: sessionNodes.has(id),
|
|
1507
|
-
};
|
|
1508
|
-
}),
|
|
1509
|
-
})),
|
|
1510
|
-
},
|
|
1511
|
-
host: lanIp === '' ? {} : { lanIp },
|
|
1512
|
-
// gh#20 W-C (host rail): each peer row also carries its display
|
|
1513
|
-
// name and version from the warm card (absent until a fetch),
|
|
1514
|
-
// and the last-contact timestamp for the health lamp. W-A's
|
|
1515
|
-
// link/verified ride along for the badge.
|
|
1516
|
-
peers: peerStore.list().map(url => {
|
|
1517
|
-
const card = cardCache.get(url)?.card;
|
|
1518
|
-
const lastContact = peerStore.lastContact(url);
|
|
1519
|
-
const lastContactOk = peerStore.lastContactOk(url);
|
|
1520
|
-
return {
|
|
1521
|
-
url,
|
|
1522
|
-
score: peerStore.score(url),
|
|
1523
|
-
...(card?.name !== undefined && card.name !== '' ? { name: card.name } : {}),
|
|
1524
|
-
...(typeof card?.version === 'string' && card.version !== '' ? { version: card.version } : {}),
|
|
1525
|
-
...(lastContact !== undefined ? { lastContactAt: lastContact } : {}),
|
|
1526
|
-
...(lastContactOk !== undefined ? { lastContactOk } : {}),
|
|
1527
|
-
...peerLinkState(url),
|
|
1528
|
-
};
|
|
1529
|
-
}),
|
|
1530
|
-
remote: (refreshRemoteRows(), (remoteRowsCache?.rows ?? []).map(row => {
|
|
1531
|
-
const canvasTeams = canvasStore.teamsOf(row.team);
|
|
1532
|
-
return canvasTeams.length > 0 ? { ...row, canvasTeams: [...canvasTeams] } : row;
|
|
1533
|
-
})),
|
|
1534
|
-
activity: recentActivity.slice(),
|
|
1535
|
-
inFlight: [...inFlightRoutes.values()].map(route => ({
|
|
1536
|
-
team: route.team,
|
|
1537
|
-
peer: route.peer,
|
|
1538
|
-
startedAt: route.startedAt,
|
|
1539
|
-
})),
|
|
1540
|
-
// Owed receipts: pending rows only — the cross-turn waits the
|
|
1541
|
-
// in-flight ring cannot show. Dead-lettered rows ride their own
|
|
1542
|
-
// list and settled ones just a count, all additive so the panel
|
|
1543
|
-
// keeps rendering plain owed rows until it learns the tiers.
|
|
1544
|
-
tasks: taskLedger.list()
|
|
1545
|
-
.filter(task => task.status === 'pending')
|
|
1546
|
-
.map(task => ({
|
|
1547
|
-
taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, status: task.status,
|
|
1548
|
-
direction: task.direction ?? 'outbound', receiptExpected: task.receiptExpected !== false,
|
|
1549
|
-
// The dispatch brief rides every row: a task id and a team
|
|
1550
|
-
// say nothing about WHAT is owed — supervision cannot tell
|
|
1551
|
-
// a nudge from an abandon without it.
|
|
1552
|
-
...(task.brief !== undefined ? { brief: task.brief } : {}),
|
|
1553
|
-
// C-ruling bucket split: a pending row is settleable while
|
|
1554
|
-
// it is young, expected, and answerable; past the stale
|
|
1555
|
-
// hour it is conversational noise waiting on the dead TTL.
|
|
1556
|
-
settleable: task.receiptExpected !== false && Date.now() - task.startedAt < 60 * 60_000,
|
|
1557
|
-
})),
|
|
1558
|
-
// C face: the stale/conversational tail as a count, so the
|
|
1559
|
-
// panel can render only truly settleable debt.
|
|
1560
|
-
tasksStaleCount: taskLedger.list().filter(task => task.status === 'pending' && (task.receiptExpected === false || Date.now() - task.startedAt >= 60 * 60_000)).length,
|
|
1561
|
-
tasksDead: taskLedger.list()
|
|
1562
|
-
.filter(task => task.status === 'dead')
|
|
1563
|
-
.map(task => ({ taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, deadAt: task.deadAt ?? task.startedAt, reason: task.reason ?? 'unspecified', ...(task.brief !== undefined ? { brief: task.brief } : {}), ...(task.direction !== undefined ? { direction: task.direction } : {}) })),
|
|
1564
|
-
// gh#31: per-task outcomes for rows that LEFT the ledger — the
|
|
1565
|
-
// chat drawer used to guess (settled, abandoned or cancelled).
|
|
1566
|
-
// Bounded to the archive (≤64), newest first, with the
|
|
1567
|
-
// controlled-vocabulary verdict each row settled by.
|
|
1568
|
-
// gh#31 follow-up: archive() is already newest first (settles
|
|
1569
|
-
// prepend), so the projection keeps that order — the earlier
|
|
1570
|
-
// .reverse() served it oldest first. Each row also says HOW it
|
|
1571
|
-
// ended (endedBy: abandon and cancel both record outcome
|
|
1572
|
-
// 'abandoned', and so does a peer's own 'abandoned' receipt),
|
|
1573
|
-
// whether a receipt came after the caller ended it (late), and
|
|
1574
|
-
// its direction when known.
|
|
1575
|
-
archived: taskLedger.archive().slice(0, ARCHIVE_CAP).map(row => ({
|
|
1576
|
-
taskId: row.taskId,
|
|
1577
|
-
team: row.team,
|
|
1578
|
-
peer: row.peer,
|
|
1579
|
-
startedAt: row.startedAt,
|
|
1580
|
-
resolvedAt: row.resolvedAt,
|
|
1581
|
-
outcome: row.outcome ?? 'unclassified',
|
|
1582
|
-
endedBy: archivedEndedBy(row),
|
|
1583
|
-
...(row.direction !== undefined ? { direction: row.direction } : {}),
|
|
1584
|
-
...(row.lateReceiptAt !== undefined ? { late: true } : {}),
|
|
1585
|
-
...(row.brief !== undefined ? { brief: row.brief } : {}),
|
|
1586
|
-
...(row.summary !== undefined && row.summary !== '' ? { summary: row.summary.slice(0, 160) } : {}),
|
|
1587
|
-
})),
|
|
1588
|
-
archivedCount: taskLedger.archive().length,
|
|
1589
|
-
// Ledger density face: the settled archive split by verdict, so
|
|
1590
|
-
// the workbench can say WHICH way those rows settled instead of
|
|
1591
|
-
// just how many. Additive and bounded (outcome vocabulary is a
|
|
1592
|
-
// closed set); rows without an envelope outcome count as
|
|
1593
|
-
// 'unclassified' rather than being dropped.
|
|
1594
|
-
archivedSplit: (() => {
|
|
1595
|
-
const split = {};
|
|
1596
|
-
for (const row of taskLedger.archive()) {
|
|
1597
|
-
const key = row.outcome ?? 'unclassified';
|
|
1598
|
-
split[key] = (split[key] ?? 0) + 1;
|
|
1599
|
-
}
|
|
1600
|
-
return split;
|
|
1601
|
-
})(),
|
|
1602
|
-
// 0.5.36 observability: the idempotency window aggregate —
|
|
1603
|
-
// window occupancy, outcome split, and cumulative claim-verdict
|
|
1604
|
-
// counters (the data the UNIQUE-ization decision waits on).
|
|
1605
|
-
// Read-only; no fingerprints cross here (they are /a2a/query's
|
|
1606
|
-
// only auth material).
|
|
1607
|
-
idempotency: idempotencyStore.stats(),
|
|
1608
|
-
// gh#41 fleet planning: each tracked peer's published canvas
|
|
1609
|
-
// org (full-card canvasOrg) from the warm card cache — the
|
|
1610
|
-
// board renders REMOTE team frames from this. Absent until a
|
|
1611
|
-
// linked fetch warmed the card.
|
|
1612
|
-
peerOrg: peerStore.list().map(url => {
|
|
1613
|
-
const card = cardCache.get(url)?.card;
|
|
1614
|
-
const org = card?.canvasOrg;
|
|
1615
|
-
return Array.isArray(org)
|
|
1616
|
-
? { url, org: org.filter((e) => e !== null && typeof e === 'object')
|
|
1617
|
-
.map(e => ({ name: typeof e.name === 'string' ? e.name : '', team: typeof e.team === 'string' ? e.team : '', members: Array.isArray(e.members) ? e.members.filter((m) => typeof m === 'string') : [] }))
|
|
1618
|
-
.filter(e => e.team !== '') }
|
|
1619
|
-
: { url, org: [] };
|
|
1620
|
-
}),
|
|
1621
|
-
// gh#9+gh#10 M1: envelope-absent deliveries during the
|
|
1622
|
-
// OriginClaim window — the operator watches this drain to
|
|
1623
|
-
// zero before closing the window.
|
|
1624
|
-
legacyInbound: legacyInboundCount,
|
|
1625
|
-
// F-observability: the boot prewarm's lifecycle — a dead or
|
|
1626
|
-
// skipped prewarm is the root shape behind "cold rows pile up
|
|
1627
|
-
// after restart" and must be readable, not a console rumor.
|
|
1628
|
-
prewarm: { ...prewarmStatus, failed: [...prewarmStatus.failed] },
|
|
1629
|
-
// F8 observability: reconciliation cadence, wake outcomes,
|
|
1630
|
-
// and per-row failure reasons with backoff and the parked
|
|
1631
|
-
// needs-repair state — the cold row's "why am I still cold".
|
|
1632
|
-
reconcile: { ...reconcileStatus, rows: [...reconcileStatus.rows] },
|
|
1633
|
-
// S1: flat node registry — local sessions and remote card
|
|
1634
|
-
// nodes in one table, zone-qualified team handles as the
|
|
1635
|
-
// stable cross-host addresses. Local ids are the session
|
|
1636
|
-
// UUIDs; remote ids are the publishing team handles. (The
|
|
1637
|
-
// `nodes` boolean above is the capability flag the panel
|
|
1638
|
-
// already consumes; this face rides its own key.)
|
|
1639
|
-
registry: {
|
|
1640
|
-
nodes: [
|
|
1641
|
-
...sessions.map(row => ({
|
|
1642
|
-
id: String(row.id),
|
|
1643
|
-
label: String(row.label ?? ''),
|
|
1644
|
-
team: String(row.team ?? ''),
|
|
1645
|
-
zone: config.team,
|
|
1646
|
-
live: row.live === true,
|
|
1647
|
-
joined: row.joined === true,
|
|
1648
|
-
...(typeof row.workspace === 'string' && row.workspace !== '' ? { workspace: row.workspace } : {}),
|
|
1649
|
-
teams: [...teamMemberships.teamsOf(String(row.id))],
|
|
1650
|
-
})),
|
|
1651
|
-
...(remoteRowsCache?.rows ?? []).map(row => ({
|
|
1652
|
-
id: String(row.team),
|
|
1653
|
-
label: String(row.name ?? ''),
|
|
1654
|
-
team: String(row.team),
|
|
1655
|
-
zone: String(row.team).split('/')[0] ?? config.team,
|
|
1656
|
-
host: String(row.origin ?? ''),
|
|
1657
|
-
...(row.workspace !== undefined && row.workspace !== '' ? { workspace: row.workspace } : {}),
|
|
1658
|
-
...(row.legacy === true ? { legacy: true } : {}),
|
|
1659
|
-
teams: [...(row.teams ?? [])],
|
|
1660
|
-
remote: true,
|
|
1661
|
-
})),
|
|
1662
|
-
],
|
|
1663
|
-
// S2: local team rosters rebuilt from member declarations.
|
|
1664
|
-
teams: (() => {
|
|
1665
|
-
const roster = new Map();
|
|
1666
|
-
for (const entry of teamMemberships.list()) {
|
|
1667
|
-
for (const team of entry.teams) {
|
|
1668
|
-
const members = roster.get(team) ?? [];
|
|
1669
|
-
members.push(entry.session);
|
|
1670
|
-
roster.set(team, members);
|
|
1671
|
-
}
|
|
1672
|
-
}
|
|
1673
|
-
return [...roster.entries()].map(([team, members]) => ({
|
|
1674
|
-
team,
|
|
1675
|
-
members,
|
|
1676
|
-
live: members.filter(member => liveRoots.has(member)).length,
|
|
1677
|
-
}));
|
|
1678
|
-
})(),
|
|
1679
|
-
// S4 audit face: join/leave attempts with outcomes — the
|
|
1680
|
-
// queryable half that rotating log lines cannot be.
|
|
1681
|
-
teamAudit: [...teamAuditRing],
|
|
1682
|
-
},
|
|
1683
|
-
// Unjoined-node reports: the one-way signal ring (design:
|
|
1684
|
-
// docs/design/unjoined-report-ingress.md), newest first.
|
|
1685
|
-
reports: [...reportRing].reverse(),
|
|
1686
|
-
});
|
|
1687
|
-
res.writeHead(200, {
|
|
1688
|
-
'Content-Type': 'application/json',
|
|
1689
|
-
'Content-Length': Buffer.byteLength(body),
|
|
1690
|
-
// Phase timing (ms since request start): prune / live-row facts /
|
|
1691
|
-
// cold-rows enumeration. Diagnostic for the poll-latency question.
|
|
1692
|
-
'X-A2A-Timing': `prune=${tPrune - t0};facts=${tFacts1 - tFacts0};cold=${tCold - tFacts1};total=${Date.now() - t0}`,
|
|
1693
|
-
});
|
|
1694
|
-
res.end(body);
|
|
1695
|
-
})();
|
|
1696
|
-
}),
|
|
1697
|
-
}), 'a2a: session-node state route');
|
|
1698
|
-
ctx.effect(() => webServer.register({
|
|
1699
|
-
kind: 'exact',
|
|
1700
|
-
path: '/__dsh_a2a/join',
|
|
1701
|
-
handler: controlRoute((req, res) => {
|
|
1702
|
-
readJsonBody(req, res, (body) => {
|
|
1703
|
-
const agent = typeof body.id === 'string' ? liveRoots.get(body.id) : undefined;
|
|
1704
|
-
if (agent === undefined) {
|
|
1705
|
-
const payload = JSON.stringify({ error: 'no live session with that id' });
|
|
1706
|
-
res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1707
|
-
res.end(payload);
|
|
1708
|
-
return;
|
|
1709
|
-
}
|
|
1710
|
-
mountSessionNode(agent);
|
|
1711
|
-
joinedSessions.add(String(agent.id));
|
|
1712
|
-
const payload = JSON.stringify({ id: body.id, label: sessionLabelOf(agent), team: sessionTeamOf(agent) });
|
|
1713
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1714
|
-
res.end(payload);
|
|
1715
|
-
});
|
|
1716
|
-
}),
|
|
1717
|
-
}), 'a2a: session-node join route');
|
|
1718
|
-
ctx.effect(() => webServer.register({
|
|
1719
|
-
kind: 'exact',
|
|
1720
|
-
path: '/__dsh_a2a/leave',
|
|
1721
|
-
handler: controlRoute((req, res) => {
|
|
1722
|
-
readJsonBody(req, res, (body) => {
|
|
1723
|
-
const id = typeof body.id === 'string' ? body.id : '';
|
|
1724
|
-
if (id !== '')
|
|
1725
|
-
sessionNodes.delete(id);
|
|
1726
|
-
joinedSessions.remove(id);
|
|
1727
|
-
// Leaving the network leaves every canvas team too: membership
|
|
1728
|
-
// without join consent would be a routing backdoor. Team
|
|
1729
|
-
// roster declarations die the same way (S2 consistency).
|
|
1730
|
-
canvasStore.dropMember(id);
|
|
1731
|
-
teamMemberships.dropSession(id);
|
|
1732
|
-
const payload = JSON.stringify({ id });
|
|
1733
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1734
|
-
res.end(payload);
|
|
1735
|
-
});
|
|
1736
|
-
}),
|
|
1737
|
-
}), 'a2a: session-node leave route');
|
|
1738
|
-
// gh#45 CLI 户口 control face (loopback/key gated like every write):
|
|
1739
|
-
// publish (deposit a signed CLI card, ≤4h by ruling), revoke, list,
|
|
1740
|
-
// and mailbox readout — the CLI polls this while alive.
|
|
1741
|
-
ctx.effect(() => webServer.register({
|
|
1742
|
-
kind: 'exact',
|
|
1743
|
-
path: '/__dsh_a2a/cli-deposit',
|
|
1744
|
-
handler: controlRoute((req, res) => {
|
|
1745
|
-
readJsonBody(req, res, (body) => {
|
|
1746
|
-
const action = body.action;
|
|
1747
|
-
let payload;
|
|
1748
|
-
if (action === 'publish') {
|
|
1749
|
-
const card = typeof body.card === 'string' ? body.card : '';
|
|
1750
|
-
const out = cliDeposits.deposit(card);
|
|
1751
|
-
payload = JSON.stringify(out.ok
|
|
1752
|
-
? { ok: true, handle: out.handle, expiresAt: out.expiresAt, cardUrl: `${ownCardUrl}/a2a/cli/${encodeURIComponent(out.handle)}` }
|
|
1753
|
-
: { ok: false, error: out.error });
|
|
1754
|
-
}
|
|
1755
|
-
else if (action === 'revoke') {
|
|
1756
|
-
payload = JSON.stringify({ ok: cliDeposits.revoke(typeof body.handle === 'string' ? body.handle : '') });
|
|
1757
|
-
}
|
|
1758
|
-
else if (action === 'inbox') {
|
|
1759
|
-
const handle = typeof body.handle === 'string' ? body.handle : '';
|
|
1760
|
-
payload = JSON.stringify({ ok: true, mail: cliDeposits.readMail(handle) });
|
|
1761
|
-
}
|
|
1762
|
-
else {
|
|
1763
|
-
payload = JSON.stringify({ ok: true, deposits: cliDeposits.list().map(row => ({ handle: row.handle, name: row.name, depositedAt: row.depositedAt, expiresAt: row.expiresAt })) });
|
|
1764
|
-
}
|
|
1765
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1766
|
-
res.end(payload);
|
|
1767
|
-
});
|
|
1768
|
-
}),
|
|
1769
|
-
}), 'a2a: cli-deposit route');
|
|
1770
|
-
// gh#41 fleet planning relay: the GUI asks THIS host to regulate a
|
|
1771
|
-
// REMOTE peer's canvas team. The node key never leaves the server —
|
|
1772
|
-
// the relay stamps the OriginClaim envelope and forwards to the
|
|
1773
|
-
// peer's /a2a/canvas-op face; the peer's link gate authorizes.
|
|
1774
|
-
ctx.effect(() => webServer.register({
|
|
1775
|
-
kind: 'exact',
|
|
1776
|
-
path: '/__dsh_a2a/fleet-canvas-op',
|
|
1777
|
-
handler: controlRoute((req, res) => {
|
|
1778
|
-
readJsonBody(req, res, async (body) => {
|
|
1779
|
-
const { peer, op, name, id } = body;
|
|
1780
|
-
const peerUrl = typeof peer === 'string' ? peer : '';
|
|
1781
|
-
const opV = typeof op === 'string' ? op : '';
|
|
1782
|
-
const nameV = typeof name === 'string' ? name : '';
|
|
1783
|
-
const idV = typeof id === 'string' ? id : '';
|
|
1784
|
-
const send = (payload) => {
|
|
1785
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1786
|
-
res.end(payload);
|
|
1787
|
-
};
|
|
1788
|
-
if (peerUrl === '' || !/^https?:\/\//.test(peerUrl) || !['create', 'add-member', 'remove-member'].includes(opV) || (opV !== 'create' && nameV === '')) {
|
|
1789
|
-
send(JSON.stringify({ ok: false, error: 'peer (http url), op (create | add-member | remove-member), and name are required' }));
|
|
1790
|
-
return;
|
|
1791
|
-
}
|
|
1792
|
-
if (!peerStore.list().includes(peerUrl)) {
|
|
1793
|
-
send(JSON.stringify({ ok: false, error: `peer ${peerUrl} is not tracked — regulate tracked peers only` }));
|
|
1794
|
-
return;
|
|
1795
|
-
}
|
|
1796
|
-
try {
|
|
1797
|
-
const target = peerUrl.replace(/\/$/, '') + '/a2a/canvas-op';
|
|
1798
|
-
const reply = await globalThis.fetch(target, {
|
|
1799
|
-
method: 'POST',
|
|
1800
|
-
headers: { 'Content-Type': 'application/json', ...originClaimHeaders() },
|
|
1801
|
-
body: JSON.stringify({ op: opV, name: nameV, ...(opV !== 'create' ? { id: idV } : {}) }),
|
|
1802
|
-
signal: AbortSignal.timeout(15_000),
|
|
1803
|
-
});
|
|
1804
|
-
const raw = await reply.text();
|
|
1805
|
-
try {
|
|
1806
|
-
const out = JSON.parse(raw);
|
|
1807
|
-
send(JSON.stringify(out));
|
|
1808
|
-
}
|
|
1809
|
-
catch {
|
|
1810
|
-
// A non-JSON reply is a peer WITHOUT the canvas-op face (a
|
|
1811
|
-
// pre-gh41 build) or a proxy page — name the shape honestly.
|
|
1812
|
-
send(JSON.stringify({ ok: false, error: `peer ${peerUrl} answered HTTP ${String(reply.status)} without JSON — it likely runs a build without the fleet canvas-op face (pre gh#41); upgrade it, or regulate its teams on that host directly` }));
|
|
1813
|
-
}
|
|
1814
|
-
}
|
|
1815
|
-
catch (error) {
|
|
1816
|
-
send(JSON.stringify({ ok: false, error: `fleet op failed: ${error instanceof Error ? error.message : String(error)}` }));
|
|
1817
|
-
}
|
|
1818
|
-
});
|
|
1819
|
-
}),
|
|
1820
|
-
}), 'a2a: fleet-canvas-op relay route');
|
|
1821
|
-
// gh#34 owner three-key panel: the remote build-out unlock knobs,
|
|
1822
|
-
// surfaced as a control face instead of "翻配置文件". GET reads the
|
|
1823
|
-
// effective values; POST writes the HOME patch layer (the
|
|
1824
|
-
// authoritative surface) AND hot-applies the two call-time keys
|
|
1825
|
-
// (teamJoinAllowlist / teamScopeInfo); announce is boot-time
|
|
1826
|
-
// publishing, so its change lands on the next host restart and the
|
|
1827
|
-
// reply says so. Same key gate as every write face.
|
|
1828
|
-
ctx.effect(() => webServer.register({
|
|
1829
|
-
kind: 'exact',
|
|
1830
|
-
path: '/__dsh_a2a/owner-settings',
|
|
1831
|
-
handler: controlRoute((req, res) => {
|
|
1832
|
-
if (req.method === 'GET') {
|
|
1833
|
-
const payload = JSON.stringify({
|
|
1834
|
-
ok: true,
|
|
1835
|
-
teamJoinAllowlist: [...ownerAllowlist],
|
|
1836
|
-
teamScopeInfo: ownerTeamScopeInfo,
|
|
1837
|
-
announce: config.announce === true,
|
|
1838
|
-
announceRestartPending: ownerAnnounceRestartPending,
|
|
1839
|
-
});
|
|
1840
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1841
|
-
res.end(payload);
|
|
1842
|
-
return;
|
|
1843
|
-
}
|
|
1844
|
-
readJsonBody(req, res, (rawBody) => {
|
|
1845
|
-
const body = rawBody;
|
|
1846
|
-
const changes = {};
|
|
1847
|
-
if (Array.isArray(body.teamJoinAllowlist)) {
|
|
1848
|
-
const list = body.teamJoinAllowlist.filter((name) => typeof name === 'string' && name !== '' && name.length <= 128);
|
|
1849
|
-
changes.teamJoinAllowlist = [...new Set(list)];
|
|
1850
|
-
}
|
|
1851
|
-
if (typeof body.teamScopeInfo === 'boolean')
|
|
1852
|
-
changes.teamScopeInfo = body.teamScopeInfo;
|
|
1853
|
-
if (typeof body.announce === 'boolean')
|
|
1854
|
-
changes.announce = body.announce;
|
|
1855
|
-
if (Object.keys(changes).length === 0) {
|
|
1856
|
-
const payload = JSON.stringify({ ok: false, error: 'no recognized keys: teamJoinAllowlist (string[]), teamScopeInfo (bool), announce (bool)' });
|
|
1857
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1858
|
-
res.end(payload);
|
|
1859
|
-
return;
|
|
1860
|
-
}
|
|
1861
|
-
try {
|
|
1862
|
-
const patchPath = join(home, 'cordis.patch.yml');
|
|
1863
|
-
const current = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '';
|
|
1864
|
-
const { yaml, restartNeeded } = applyOwnerSettings(current, changes);
|
|
1865
|
-
mkdirSync(home, { recursive: true });
|
|
1866
|
-
writeFileSync(patchPath, yaml, 'utf8');
|
|
1867
|
-
// Hot-apply the two call-time keys.
|
|
1868
|
-
if (changes.teamJoinAllowlist !== undefined)
|
|
1869
|
-
ownerAllowlist = [...changes.teamJoinAllowlist];
|
|
1870
|
-
if (changes.teamScopeInfo !== undefined)
|
|
1871
|
-
ownerTeamScopeInfo = changes.teamScopeInfo;
|
|
1872
|
-
if (changes.announce !== undefined && changes.announce !== (config.announce === true))
|
|
1873
|
-
ownerAnnounceRestartPending = true;
|
|
1874
|
-
if (changes.announce !== undefined && changes.announce === (config.announce === true))
|
|
1875
|
-
ownerAnnounceRestartPending = false;
|
|
1876
|
-
logger.info(`a2a: owner settings updated (${Object.keys(changes).join(', ')}${restartNeeded ? ' — restart pending for announce' : ''})`);
|
|
1877
|
-
const payload = JSON.stringify({ ok: true, restartNeeded, keys: Object.keys(changes) });
|
|
1878
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1879
|
-
res.end(payload);
|
|
1880
|
-
}
|
|
1881
|
-
catch (error) {
|
|
1882
|
-
const payload = JSON.stringify({ ok: false, error: `patch-layer write failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
1883
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1884
|
-
res.end(payload);
|
|
1885
|
-
}
|
|
1886
|
-
});
|
|
1887
|
-
}),
|
|
1888
|
-
}), 'a2a: owner-settings route');
|
|
1889
|
-
// Groups control: create/remove named groups and assign sessions to
|
|
1890
|
-
// them (an empty name unassigns). Same guard as join/leave — the
|
|
1891
|
-
// group taxonomy enumerates session ids, so it stays behind the key.
|
|
1892
|
-
// Deprecated as of 0.6.6 (canvas teams supersede manual grouping);
|
|
1893
|
-
// kept working for old panels.
|
|
1894
|
-
ctx.effect(() => webServer.register({
|
|
1895
|
-
kind: 'exact',
|
|
1896
|
-
path: '/__dsh_a2a/groups',
|
|
1897
|
-
handler: controlRoute((req, res) => {
|
|
1898
|
-
readJsonBody(req, res, (body) => {
|
|
1899
|
-
const action = body.action;
|
|
1900
|
-
let payload;
|
|
1901
|
-
if (action === 'create') {
|
|
1902
|
-
const name = typeof body.name === 'string' ? body.name : '';
|
|
1903
|
-
const stored = groupStore.create(name);
|
|
1904
|
-
payload = JSON.stringify(stored === undefined ? { ok: false, error: 'invalid name or group cap reached' } : { ok: true, name: stored, groups: groupStore.list() });
|
|
1905
|
-
}
|
|
1906
|
-
else if (action === 'remove') {
|
|
1907
|
-
const name = typeof body.name === 'string' ? body.name : '';
|
|
1908
|
-
payload = JSON.stringify({ ok: groupStore.remove(name), groups: groupStore.list() });
|
|
1909
|
-
}
|
|
1910
|
-
else if (action === 'assign') {
|
|
1911
|
-
const id = typeof body.id === 'string' ? body.id : '';
|
|
1912
|
-
const name = typeof body.name === 'string' ? body.name : undefined;
|
|
1913
|
-
if (id === '' || name === undefined) {
|
|
1914
|
-
payload = JSON.stringify({ ok: false, error: 'id and name are required' });
|
|
1915
|
-
}
|
|
1916
|
-
else {
|
|
1917
|
-
payload = JSON.stringify({ ok: groupStore.assign(id, name), groups: groupStore.list() });
|
|
1918
|
-
}
|
|
1919
|
-
}
|
|
1920
|
-
else {
|
|
1921
|
-
payload = JSON.stringify({ ok: false, error: 'unknown action' });
|
|
1922
|
-
}
|
|
1923
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1924
|
-
res.end(payload);
|
|
1925
|
-
});
|
|
1926
|
-
}),
|
|
1927
|
-
}), 'a2a: session-group route');
|
|
1928
|
-
// Canvas control: create/remove named multi-member teams and manage
|
|
1929
|
-
// membership. Same key guard as join/leave — membership enumerates
|
|
1930
|
-
// session ids. A member must be a joined session (live node or
|
|
1931
|
-
// remembered intent): canvas routing only reaches sessions the user
|
|
1932
|
-
// put on the network by gesture — no routing backdoor over unjoined
|
|
1933
|
-
// sessions.
|
|
1934
|
-
ctx.effect(() => webServer.register({
|
|
1935
|
-
kind: 'exact',
|
|
1936
|
-
path: '/__dsh_a2a/canvas',
|
|
1937
|
-
handler: controlRoute((req, res) => {
|
|
1938
|
-
readJsonBody(req, res, (body) => {
|
|
1939
|
-
const action = body.action;
|
|
1940
|
-
let payload;
|
|
1941
|
-
if (action === 'create') {
|
|
1942
|
-
const name = typeof body.name === 'string' ? body.name : '';
|
|
1943
|
-
const stored = canvasStore.create(name);
|
|
1944
|
-
payload = JSON.stringify(stored === undefined ? { ok: false, error: 'invalid name or team cap reached' } : { ok: true, name: stored, teams: canvasStore.list() });
|
|
1945
|
-
}
|
|
1946
|
-
else if (action === 'remove') {
|
|
1947
|
-
const name = typeof body.name === 'string' ? body.name : '';
|
|
1948
|
-
payload = JSON.stringify({ ok: canvasStore.remove(name), teams: canvasStore.list() });
|
|
1949
|
-
}
|
|
1950
|
-
else if (action === 'add-member') {
|
|
1951
|
-
const name = typeof body.name === 'string' ? body.name : '';
|
|
1952
|
-
const id = typeof body.id === 'string' ? body.id : '';
|
|
1953
|
-
// Dual member shape: a local session id must be a joined node;
|
|
1954
|
-
// a remote ref is a zone-qualified team handle whose shape this
|
|
1955
|
-
// gate checks and whose existence the UI picker (directory
|
|
1956
|
-
// rows) vouches for. Humans compose both via the workbench.
|
|
1957
|
-
const memberValid = isRemoteMemberRef(id)
|
|
1958
|
-
? validRemoteMemberRef(id)
|
|
1959
|
-
: id !== '' && (sessionNodes.has(id) || joinedSessions.has(id));
|
|
1960
|
-
if (name === '' || !memberValid) {
|
|
1961
|
-
payload = JSON.stringify({ ok: false, error: 'name and a member (joined session id, or a zone-qualified remote team handle like zone/a1b2c3d4) are required' });
|
|
1962
|
-
}
|
|
1963
|
-
else {
|
|
1964
|
-
canvasStore.create(name);
|
|
1965
|
-
payload = JSON.stringify({ ok: canvasStore.addMember(name, id), teams: canvasStore.list(), members: canvasStore.membersOf(name) });
|
|
1966
|
-
}
|
|
1967
|
-
}
|
|
1968
|
-
else if (action === 'remove-member') {
|
|
1969
|
-
const name = typeof body.name === 'string' ? body.name : '';
|
|
1970
|
-
const id = typeof body.id === 'string' ? body.id : '';
|
|
1971
|
-
if (name === '' || id === '') {
|
|
1972
|
-
payload = JSON.stringify({ ok: false, error: 'name and id are required' });
|
|
1973
|
-
}
|
|
1974
|
-
else {
|
|
1975
|
-
payload = JSON.stringify({ ok: canvasStore.removeMember(name, id), teams: canvasStore.list(), members: canvasStore.membersOf(name) });
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
else {
|
|
1979
|
-
payload = JSON.stringify({ ok: false, error: 'unknown action' });
|
|
1980
|
-
}
|
|
1981
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1982
|
-
res.end(payload);
|
|
1983
|
-
});
|
|
1984
|
-
}),
|
|
1985
|
-
}), 'a2a: session-canvas route');
|
|
1986
|
-
// Layout face (contract v2): whole-document GET + save/reset for the
|
|
1987
|
-
// stage's spatial presentation state. Validated/clamped server-side by
|
|
1988
|
-
// LayoutStore so a buggy client cannot poison persisted geometry.
|
|
1989
|
-
ctx.effect(() => webServer.register({
|
|
1990
|
-
kind: 'exact',
|
|
1991
|
-
path: '/__dsh_a2a/canvas-layout',
|
|
1992
|
-
handler: controlRoute((req, res) => {
|
|
1993
|
-
if (req.method === 'GET') {
|
|
1994
|
-
const payload = JSON.stringify({ ok: true, layout: layoutStore.get() });
|
|
1995
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1996
|
-
res.end(payload);
|
|
1997
|
-
return;
|
|
1998
|
-
}
|
|
1999
|
-
readJsonBody(req, res, (body) => {
|
|
2000
|
-
const action = body.action;
|
|
2001
|
-
if (action === 'reset') {
|
|
2002
|
-
layoutStore.reset();
|
|
2003
|
-
const payload = JSON.stringify({ ok: true, layout: null });
|
|
2004
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2005
|
-
res.end(payload);
|
|
2006
|
-
return;
|
|
2007
|
-
}
|
|
2008
|
-
if (action === 'save') {
|
|
2009
|
-
const accepted = layoutStore.save(body.layout);
|
|
2010
|
-
const payload = JSON.stringify(accepted ? { ok: true, layout: layoutStore.get() } : { ok: false, error: 'payload is not a version-1 layout document' });
|
|
2011
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2012
|
-
res.end(payload);
|
|
2013
|
-
return;
|
|
2014
|
-
}
|
|
2015
|
-
const payload = JSON.stringify({ ok: false, error: 'unknown action' });
|
|
2016
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2017
|
-
res.end(payload);
|
|
2018
|
-
}, MAX_LAYOUT_BODY_BYTES);
|
|
2019
|
-
}),
|
|
2020
|
-
}), 'a2a: canvas-layout route');
|
|
2021
|
-
// The dsh-a2a-munder-difflin floor stage: a same-origin full-page
|
|
2022
|
-
// surface adapted from the Munder Difflin office renderer (stage/,
|
|
2023
|
-
// NOTICE). Serves the built Vite tree with a traversal-guarded map.
|
|
2024
|
-
ctx.effect(() => webServer.register({
|
|
2025
|
-
kind: 'prefix',
|
|
2026
|
-
path: '/__dsh_a2a_canvas',
|
|
2027
|
-
handler: (req, res) => {
|
|
2028
|
-
const rootDir = fileURLToPath(new URL('../assets/stageDist', import.meta.url));
|
|
2029
|
-
// Directory-classic resolution: the bare mount 301s onto itself
|
|
2030
|
-
// with a slash, so the shell's `./assets` references resolve
|
|
2031
|
-
// inside this tree instead of 404ing against the web root.
|
|
2032
|
-
const MOUNT = '/__dsh_a2a_canvas';
|
|
2033
|
-
const resolved = resolveStageMount(req.url ?? '/', MOUNT);
|
|
2034
|
-
if (resolved.redirectTo !== undefined) {
|
|
2035
|
-
res.writeHead(301, { Location: resolved.redirectTo, 'Cache-Control': 'no-store', 'X-A2A-Stage': 'redirect' });
|
|
2036
|
-
res.end();
|
|
2037
|
-
return;
|
|
2038
|
-
}
|
|
2039
|
-
const rel = decodeURIComponent(resolved.rel).replace(/\\/g, '/');
|
|
2040
|
-
if (rel.includes('..')) {
|
|
2041
|
-
res.writeHead(403, { 'Content-Type': 'text/plain', 'X-A2A-Stage': 'traversal' });
|
|
2042
|
-
res.end();
|
|
2043
|
-
return;
|
|
2044
|
-
}
|
|
2045
|
-
try {
|
|
2046
|
-
const file = readFileSync(join(rootDir, rel));
|
|
2047
|
-
const ext = rel.slice(rel.lastIndexOf('.') + 1);
|
|
2048
|
-
const types = {
|
|
2049
|
-
html: 'text/html; charset=utf-8',
|
|
2050
|
-
js: 'text/javascript; charset=utf-8',
|
|
2051
|
-
mjs: 'text/javascript; charset=utf-8',
|
|
2052
|
-
css: 'text/css; charset=utf-8',
|
|
2053
|
-
png: 'image/png',
|
|
2054
|
-
json: 'application/json; charset=utf-8',
|
|
2055
|
-
svg: 'image/svg+xml',
|
|
2056
|
-
};
|
|
2057
|
-
res.writeHead(200, { 'Content-Type': types[ext] ?? 'application/octet-stream', 'Cache-Control': 'no-store', 'X-A2A-Stage': 'ok:' + ext });
|
|
2058
|
-
res.end(file);
|
|
2059
|
-
}
|
|
2060
|
-
catch (err) {
|
|
2061
|
-
res.writeHead(404, { 'Content-Type': 'text/plain', 'X-A2A-Stage': 'miss' });
|
|
2062
|
-
res.end('stage-miss rel=' + rel + ' rootDir=' + rootDir + ' err=' + String(err));
|
|
2063
|
-
}
|
|
2064
|
-
},
|
|
2065
|
-
}), 'a2a: canvas-stage page');
|
|
2066
|
-
// The dsh-a2a-nexus stage: Three.js infinite-canvas topology viewer.
|
|
2067
|
-
// Serves the built Vite tree from assets/nexusDist with a traversal-
|
|
2068
|
-
// guarded file map (same discipline as canvas-stage).
|
|
2069
|
-
ctx.effect(() => webServer.register({
|
|
2070
|
-
kind: 'prefix',
|
|
2071
|
-
path: '/__dsh_a2a_nexus',
|
|
2072
|
-
handler: (req, res) => {
|
|
2073
|
-
const rootDirNexus = fileURLToPath(new URL('../assets/nexusDist', import.meta.url));
|
|
2074
|
-
// Same directory-classic resolution as the canvas mount: the bare
|
|
2075
|
-
// `/__dsh_a2a_nexus` form used to serve the shell verbatim, whose
|
|
2076
|
-
// `./assets` then 404'd at the web root — a silent black viewer.
|
|
2077
|
-
const MOUNT = '/__dsh_a2a_nexus';
|
|
2078
|
-
const resolved = resolveStageMount(req.url ?? '/', MOUNT);
|
|
2079
|
-
if (resolved.redirectTo !== undefined) {
|
|
2080
|
-
res.writeHead(301, { Location: resolved.redirectTo, 'Cache-Control': 'no-store' });
|
|
2081
|
-
res.end();
|
|
2082
|
-
return;
|
|
2083
|
-
}
|
|
2084
|
-
const rel = decodeURIComponent(resolved.rel).replace(/\\/g, '/');
|
|
2085
|
-
if (rel.includes('..')) {
|
|
2086
|
-
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
2087
|
-
res.end();
|
|
2088
|
-
return;
|
|
2089
|
-
}
|
|
2090
|
-
try {
|
|
2091
|
-
const file = readFileSync(join(rootDirNexus, rel));
|
|
2092
|
-
const ext = rel.slice(rel.lastIndexOf('.') + 1);
|
|
2093
|
-
const types = {
|
|
2094
|
-
html: 'text/html; charset=utf-8',
|
|
2095
|
-
js: 'text/javascript; charset=utf-8',
|
|
2096
|
-
mjs: 'text/javascript; charset=utf-8',
|
|
2097
|
-
css: 'text/css; charset=utf-8',
|
|
2098
|
-
png: 'image/png',
|
|
2099
|
-
json: 'application/json; charset=utf-8',
|
|
2100
|
-
svg: 'image/svg+xml',
|
|
2101
|
-
};
|
|
2102
|
-
res.writeHead(200, { 'Content-Type': types[ext] ?? 'application/octet-stream', 'Cache-Control': 'no-store' });
|
|
2103
|
-
// gh#9 §10 GUI key plumbing (REVIEW M3 fix): the shell learns
|
|
2104
|
-
// the control-plane key ONLY on the loopback leniency face —
|
|
2105
|
-
// a remote bare GET of the page serves the shell WITHOUT the
|
|
2106
|
-
// key (the reviewer proved the pre-fix shape leaked the
|
|
2107
|
-
// credential to any network-reachable fetcher on a
|
|
2108
|
-
// 0.0.0.0-bound host, contradicting §10's purpose). Loopback
|
|
2109
|
-
// localhost is exactly §10.2's stated leniency posture; keyed
|
|
2110
|
-
// hosts behind tunnels use originClaimUrl-style operator
|
|
2111
|
-
// plumbing instead.
|
|
2112
|
-
if (ext === 'html' && config.apiKey !== '' && controlTrustedWithoutKey(req)) {
|
|
2113
|
-
const injected = file.toString('utf8').replace('</head>', `<script>window.__DSH_A2A_KEY__=${JSON.stringify(config.apiKey)};</script></head>`);
|
|
2114
|
-
res.end(Buffer.from(injected, 'utf8'));
|
|
2115
|
-
return;
|
|
2116
|
-
}
|
|
2117
|
-
res.end(file);
|
|
2118
|
-
}
|
|
2119
|
-
catch {
|
|
2120
|
-
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2121
|
-
res.end();
|
|
2122
|
-
}
|
|
2123
|
-
},
|
|
2124
|
-
}), 'a2a: nexus-stage page');
|
|
1429
|
+
// gh#77 B1: the admin faces live in src/faces/admin.ts.
|
|
1430
|
+
registerAdminFaces(webServer, {
|
|
1431
|
+
ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, 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, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, verifyOriginClaim, callerLinkedToThisHost, cachedCardFetch,
|
|
1432
|
+
indexUrl: import.meta.url,
|
|
1433
|
+
get cardCache() { return cardCache; },
|
|
1434
|
+
get remoteRowsCache() { return remoteRowsCache; },
|
|
1435
|
+
get legacyInboundCount() { return legacyInboundCount; },
|
|
1436
|
+
get ownCardUrl() { return ownCardUrl; },
|
|
1437
|
+
get ownerAllowlist() { return ownerAllowlist; },
|
|
1438
|
+
set ownerAllowlist(v) { ownerAllowlist = v; },
|
|
1439
|
+
get ownerTeamScopeInfo() { return ownerTeamScopeInfo; },
|
|
1440
|
+
set ownerTeamScopeInfo(v) { ownerTeamScopeInfo = v; },
|
|
1441
|
+
get ownerAnnounceRestartPending() { return ownerAnnounceRestartPending; },
|
|
1442
|
+
set ownerAnnounceRestartPending(v) { ownerAnnounceRestartPending = v; },
|
|
1443
|
+
});
|
|
2125
1444
|
});
|
|
2126
1445
|
}
|
|
2127
1446
|
if (config.announce) {
|
|
@@ -2971,6 +2290,24 @@ ${message}`;
|
|
|
2971
2290
|
if (canvasStore.remoteMembersOf(name).includes(caller))
|
|
2972
2291
|
declared.add(canvasTeamOf(name));
|
|
2973
2292
|
}
|
|
2293
|
+
// gh#71 fix ②: the warm card cache's peer canvasOrg is the same-zone
|
|
2294
|
+
// cross-host basis too — an org entry naming BOTH the caller's handle
|
|
2295
|
+
// and one of THIS host's own handles is a shared team (双向识别: the
|
|
2296
|
+
// composition gesture happened on the other host's board; both ends
|
|
2297
|
+
// are named). Read-only over the cache; a caller org that never names
|
|
2298
|
+
// an own handle still refuses (V5 preserved).
|
|
2299
|
+
const ownHandles = new Set([config.team, ...joinedSessions.list().map(id => `${config.team}/${id8(id)}`)]);
|
|
2300
|
+
for (const cached of cardCache.values()) {
|
|
2301
|
+
const org = cached.card?.canvasOrg;
|
|
2302
|
+
if (!Array.isArray(org))
|
|
2303
|
+
continue;
|
|
2304
|
+
for (const e of org) {
|
|
2305
|
+
if (typeof e.team !== 'string' || e.team === '' || !Array.isArray(e.members))
|
|
2306
|
+
continue;
|
|
2307
|
+
if (e.members.includes(caller) && e.members.some(m => typeof m === 'string' && ownHandles.has(m)))
|
|
2308
|
+
declared.add(e.team);
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2974
2311
|
}
|
|
2975
2312
|
const routable = new Set([config.team]);
|
|
2976
2313
|
for (const id of joinedSessions.list())
|
|
@@ -3011,6 +2348,20 @@ ${message}`;
|
|
|
3011
2348
|
}
|
|
3012
2349
|
for (const name of canvasStore.teamsOf(targetSession))
|
|
3013
2350
|
targetLinks.add(canvasTeamOf(name));
|
|
2351
|
+
// gh#71 fix ②: the warm cache's org basis at target level too —
|
|
2352
|
+
// an org entry naming BOTH the caller and the target handle is
|
|
2353
|
+
// the shared team (same 双向识别 rule as the host-level basis).
|
|
2354
|
+
for (const cached of cardCache.values()) {
|
|
2355
|
+
const org = cached.card?.canvasOrg;
|
|
2356
|
+
if (!Array.isArray(org))
|
|
2357
|
+
continue;
|
|
2358
|
+
for (const e of org) {
|
|
2359
|
+
if (typeof e.team !== 'string' || e.team === '' || !Array.isArray(e.members))
|
|
2360
|
+
continue;
|
|
2361
|
+
if (e.members.includes(caller) && e.members.includes(targetTeam))
|
|
2362
|
+
targetLinks.add(e.team);
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
3014
2365
|
}
|
|
3015
2366
|
}
|
|
3016
2367
|
else {
|
|
@@ -3178,664 +2529,48 @@ ${message}`;
|
|
|
3178
2529
|
return { ok: true, id, messages: entries.map(entry => ({ role: entry.role, text: entry.text, ...(entry.time !== undefined ? { time: entry.time } : {}) })) };
|
|
3179
2530
|
}
|
|
3180
2531
|
whenWebServerSettled((webServer) => {
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
res.end(payload);
|
|
3224
|
-
};
|
|
3225
|
-
if (req.method !== 'POST') {
|
|
3226
|
-
send(200, JSON.stringify({ ok: false, error: 'POST required' }));
|
|
3227
|
-
return;
|
|
3228
|
-
}
|
|
3229
|
-
const chunks = [];
|
|
3230
|
-
let size = 0;
|
|
3231
|
-
req.on('data', (chunk) => { size += chunk.length; if (size > 16 * 1024) {
|
|
3232
|
-
chunks.length = 0;
|
|
3233
|
-
return;
|
|
3234
|
-
} chunks.push(chunk); });
|
|
3235
|
-
req.on('end', () => {
|
|
3236
|
-
void (async () => {
|
|
3237
|
-
try {
|
|
3238
|
-
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
3239
|
-
const claim = await verifyOriginClaim(req);
|
|
3240
|
-
if (!claim.ok) {
|
|
3241
|
-
logger.warn(`a2a: canvas-op claim failed (${claim.reason}) — caller=${String(req.headers['x-a2a-caller'] ?? '')} url=${String(req.headers['x-a2a-caller-url'] ?? '')} ts=${String(req.headers['x-a2a-caller-ts'] ?? '')}`);
|
|
3242
|
-
send(200, JSON.stringify({ ok: false, error: `untrusted-origin: OriginClaim verification failed (${claim.reason}) — canvas regulation requires a signed envelope`, code: -32000 }));
|
|
3243
|
-
return;
|
|
3244
|
-
}
|
|
3245
|
-
if (!callerLinkedToThisHost(claim.caller, claim.callerUrl, claim.card)) {
|
|
3246
|
-
recordActivity('in', 'fleet-canvas', claim.caller, false);
|
|
3247
|
-
send(200, JSON.stringify({ ok: false, error: `caller "${claim.caller}" is not linked to this host — canvas regulation requires the operator link (bilateral seed, canvas ref, or same-host membership)` }));
|
|
3248
|
-
return;
|
|
3249
|
-
}
|
|
3250
|
-
const op = body.op;
|
|
3251
|
-
const name = typeof body.name === 'string' ? body.name : '';
|
|
3252
|
-
const id = typeof body.id === 'string' ? body.id : '';
|
|
3253
|
-
let payload;
|
|
3254
|
-
if (op === 'create') {
|
|
3255
|
-
const stored = canvasStore.create(name);
|
|
3256
|
-
payload = stored === undefined ? { ok: false, error: 'invalid name or team cap reached' } : { ok: true, members: canvasStore.membersOf(stored) };
|
|
3257
|
-
}
|
|
3258
|
-
else if (op === 'add-member') {
|
|
3259
|
-
const memberValid = isRemoteMemberRef(id) ? validRemoteMemberRef(id) : id !== '' && (sessionNodes.has(id) || joinedSessions.has(id));
|
|
3260
|
-
payload = name === '' || !memberValid
|
|
3261
|
-
? { ok: false, error: 'name and a member (joined session id, or a zone-qualified remote team handle) are required' }
|
|
3262
|
-
: (canvasStore.create(name), { ok: canvasStore.addMember(name, id), members: canvasStore.membersOf(name) });
|
|
3263
|
-
}
|
|
3264
|
-
else if (op === 'remove-member') {
|
|
3265
|
-
payload = { ok: name !== '' && canvasStore.removeMember(name, id), members: name !== '' ? canvasStore.membersOf(name) : undefined };
|
|
3266
|
-
}
|
|
3267
|
-
else {
|
|
3268
|
-
payload = { ok: false, error: 'op must be create | add-member | remove-member' };
|
|
3269
|
-
}
|
|
3270
|
-
recordActivity('in', 'fleet-canvas', `${claim.caller}:${String(op)}`, payload.ok);
|
|
3271
|
-
logger.info(`a2a: fleet canvas op from ${claim.caller} (${String(op)} ${name}${id !== '' ? ` ${id}` : ''}) -> ${payload.ok ? 'applied' : `refused: ${payload.error ?? '?'}`}`);
|
|
3272
|
-
send(200, JSON.stringify(payload));
|
|
3273
|
-
}
|
|
3274
|
-
catch (error) {
|
|
3275
|
-
send(200, JSON.stringify({ ok: false, error: `canvas-op failed: ${error instanceof Error ? error.message : String(error)}` }));
|
|
3276
|
-
}
|
|
3277
|
-
})();
|
|
3278
|
-
});
|
|
3279
|
-
},
|
|
3280
|
-
}), 'a2a: fleet canvas-op peer route');
|
|
3281
|
-
ctx.effect(() => webServer.register({
|
|
3282
|
-
kind: 'exact',
|
|
3283
|
-
path: '/a2a/direct',
|
|
3284
|
-
handler: async (req, res) => {
|
|
3285
|
-
const chunks = [];
|
|
3286
|
-
let size = 0;
|
|
3287
|
-
// B5 enforcement: 512 KiB hard cap. An oversized body is rejected,
|
|
3288
|
-
// never truncated and never connection-killed mid-read — buffering
|
|
3289
|
-
// stops at the crossing chunk, the stream drains to `end`, and the
|
|
3290
|
-
// peer receives one structured 413 with a wire error code (the old
|
|
3291
|
-
// behavior tore the socket down with no diagnosis at all).
|
|
3292
|
-
let overflowed = false;
|
|
3293
|
-
const declared = Number.parseInt(String(req.headers['content-length'] ?? ''), 10);
|
|
3294
|
-
if (Number.isFinite(declared) && !withinRouteBodyCap(declared))
|
|
3295
|
-
overflowed = true;
|
|
3296
|
-
req.on('data', (chunk) => {
|
|
3297
|
-
if (overflowed)
|
|
3298
|
-
return;
|
|
3299
|
-
const next = size + chunk.length;
|
|
3300
|
-
if (!withinRouteBodyCap(next)) {
|
|
3301
|
-
overflowed = true;
|
|
3302
|
-
chunks.length = 0;
|
|
3303
|
-
return;
|
|
3304
|
-
}
|
|
3305
|
-
size = next;
|
|
3306
|
-
chunks.push(chunk);
|
|
3307
|
-
});
|
|
3308
|
-
/* v8 ignore next 6 -- client aborts surface as 'close' via the shared
|
|
3309
|
-
webServer's per-request guard; a raw req 'error' needs a malformed
|
|
3310
|
-
HTTP stream the webServer rejects before this handler exists */
|
|
3311
|
-
req.on('error', () => {
|
|
3312
|
-
if (!res.headersSent) {
|
|
3313
|
-
res.writeHead(400);
|
|
3314
|
-
res.end();
|
|
3315
|
-
}
|
|
3316
|
-
});
|
|
3317
|
-
req.on('end', async () => {
|
|
3318
|
-
if (overflowed) {
|
|
3319
|
-
if (!res.writableEnded && !res.headersSent) {
|
|
3320
|
-
const payload = JSON.stringify({ error: 'payload too large', code: WIRE_ERROR_PAYLOAD_TOO_LARGE });
|
|
3321
|
-
res.writeHead(413, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3322
|
-
res.end(payload);
|
|
3323
|
-
}
|
|
3324
|
-
return;
|
|
3325
|
-
}
|
|
3326
|
-
let body;
|
|
3327
|
-
try {
|
|
3328
|
-
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
3329
|
-
}
|
|
3330
|
-
catch {
|
|
3331
|
-
const payload = JSON.stringify({ error: 'malformed body', code: -32000 });
|
|
3332
|
-
res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3333
|
-
res.end(payload);
|
|
3334
|
-
return;
|
|
3335
|
-
}
|
|
3336
|
-
// V2 (gh#7, S1): the delivery face honors the apiKey gate exactly
|
|
3337
|
-
// like the control face — a configured key without this header
|
|
3338
|
-
// used to let anyone reachable steer sessions while the control
|
|
3339
|
-
// routes demanded the key, making it decorative for delivery.
|
|
3340
|
-
// Empty-key deployments keep the documented open posture.
|
|
3341
|
-
if (config.apiKey !== '') {
|
|
3342
|
-
const presented = String(req.headers['x-api-key'] ?? '');
|
|
3343
|
-
if (!secretEqual(presented, config.apiKey)) {
|
|
3344
|
-
const payload = JSON.stringify({ error: 'unauthorized: X-API-Key required on the delivery face (apiKey is configured)', code: -32000 });
|
|
3345
|
-
res.writeHead(401, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3346
|
-
res.end(payload);
|
|
3347
|
-
return;
|
|
3348
|
-
}
|
|
3349
|
-
}
|
|
3350
|
-
const team = typeof body.team === 'string' ? body.team : '';
|
|
3351
|
-
const message = typeof body.message === 'string' ? body.message : '';
|
|
3352
|
-
const caller = typeof body.caller_session === 'string' ? body.caller_session : '';
|
|
3353
|
-
// P2 receipt-callback: where THIS node's receipt should route on
|
|
3354
|
-
// the caller's side — the caller knows its routable address best
|
|
3355
|
-
// (a joined session's node team, e.g. via the transport face's
|
|
3356
|
-
// callbackTarget mapping). Bounded; absent → the caller label
|
|
3357
|
-
// plays its usual role.
|
|
3358
|
-
const callback = typeof body.callback === 'string' && body.callback !== '' && body.callback.length <= 128 ? body.callback : '';
|
|
3359
|
-
const noWait = body.wait === false;
|
|
3360
|
-
const contextId = typeof body.context_id === 'string' && body.context_id !== '' ? body.context_id : `ctx-${Math.random().toString(16).slice(2, 10)}`;
|
|
3361
|
-
// The caller-born task id (idempotency key): echo it when present
|
|
3362
|
-
// so the receipt header correlates with the caller's own result;
|
|
3363
|
-
// pre-0.5.3 callers carry none and this node mints one.
|
|
3364
|
-
const taskId = typeof body.task_id === 'string' && body.task_id !== '' ? body.task_id : `direct-${Math.random().toString(16).slice(2, 10)}`;
|
|
3365
|
-
if (team === '' || message === '') {
|
|
3366
|
-
const payload = JSON.stringify({ error: 'team and message are required', code: -32000 });
|
|
3367
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3368
|
-
res.end(payload);
|
|
3369
|
-
return;
|
|
3370
|
-
}
|
|
3371
|
-
// Phase-3 admission (owner ruling 2026-09-01, decision-seat
|
|
3372
|
-
// filed): an inbound direct delivery must show a SHARED team.
|
|
3373
|
-
// Local-zone callers are judged synchronously and exactly — the
|
|
3374
|
-
// declaration lives in this host's own roster store, so the
|
|
3375
|
-
// Anonymous-delivery refusal (S3 phase-3b, test-seat finding
|
|
3376
|
-
// direct-001649ab): a delivery with NO caller_session field
|
|
3377
|
-
// skips the admission gate entirely (nothing to judge) — that
|
|
3378
|
-
// anonymous hole let any local process or reachable peer inject
|
|
3379
|
-
// into a live session. The protocol contract is caller_session;
|
|
3380
|
-
// its absence is now refused instead of silently waved through.
|
|
3381
|
-
// (0.6.15: the rule now lives in a2aCallerRefusal so the
|
|
3382
|
-
// read-only /a2a/log face admits callers by exactly the same
|
|
3383
|
-
// standard as message delivery.)
|
|
3384
|
-
const callerRefusal = a2aCallerRefusal(caller, team);
|
|
3385
|
-
if (callerRefusal !== undefined) {
|
|
3386
|
-
recordActivity('in', team, caller === '' ? 'anonymous' : caller, false);
|
|
3387
|
-
const payload = JSON.stringify({ error: callerRefusal, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
|
|
3388
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3389
|
-
res.end(payload);
|
|
3390
|
-
return;
|
|
3391
|
-
}
|
|
3392
|
-
// gh#9+gh#10 OriginClaim on the delivery face (M1): an envelope
|
|
3393
|
-
// that is present but unverifiable rejects untrusted-origin; an
|
|
3394
|
-
// ABSENT envelope during the window walks on with a counted
|
|
3395
|
-
// legacyInbound warn (the operator watches the tail drain), and
|
|
3396
|
-
// after the window closes it rejects like a failed signature —
|
|
3397
|
-
// N3 proves a NAME's linkability, never key possession.
|
|
3398
|
-
const claim = await verifyOriginClaim(req);
|
|
3399
|
-
if (!claim.ok) {
|
|
3400
|
-
if (claim.reason !== 'absent' || config.originClaimWindow === false) {
|
|
3401
|
-
recordActivity('in', team, caller, false);
|
|
3402
|
-
const payload = JSON.stringify({ error: `untrusted-origin: OriginClaim verification failed (${claim.reason}) — present a signed envelope (X-A2A-Caller/-Url/-Ts/-Sig with your card key) or upgrade your node`, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
|
|
3403
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3404
|
-
res.end(payload);
|
|
3405
|
-
return;
|
|
3406
|
-
}
|
|
3407
|
-
legacyInboundCount += 1;
|
|
3408
|
-
}
|
|
3409
|
-
// Server-side idempotency (P3/B3): one key, one execution inside
|
|
3410
|
-
// the window. Same key + same payload fingerprint ⇒ replay (409,
|
|
3411
|
-
// -32003, replay:true — the prior attempt stays authoritative);
|
|
3412
|
-
// same key + different payload ⇒ hard conflict (409, -32002).
|
|
3413
|
-
// Checked BEFORE any steering or ledger correlation: a replay must
|
|
3414
|
-
// never re-enter the target session, no matter how it settles.
|
|
3415
|
-
const idemFingerprint = peerPayloadFingerprint({ caller, message, noWait, team });
|
|
3416
|
-
const idemVerdict = idempotencyStore.claim(taskId, idemFingerprint);
|
|
3417
|
-
if (idemVerdict !== 'fresh') {
|
|
3418
|
-
const replay = idemVerdict === 'replay';
|
|
3419
|
-
// 0.5.36 observability: conflict = caller bug (graph-loop prompt
|
|
3420
|
-
// minting / key management) — warn loudly so it is never misread
|
|
3421
|
-
// as the normal-depth-defense zero-traffic shape. Replays stay
|
|
3422
|
-
// silent by design.
|
|
3423
|
-
if (!replay)
|
|
3424
|
-
logger.warn(`a2a: idempotency CONFLICT on task ${taskId.slice(0, 80)} — same key reused with a different payload (caller bug; never auto-retry)`);
|
|
3425
|
-
const payload = JSON.stringify({
|
|
3426
|
-
error: replay ? 'duplicate task id within the idempotency window' : 'task id reused with a different payload',
|
|
3427
|
-
code: replay ? WIRE_ERROR_REPLAY_REJECTED : WIRE_ERROR_IDEMPOTENCY_CONFLICT,
|
|
3428
|
-
task_id: taskId,
|
|
3429
|
-
replay,
|
|
3430
|
-
});
|
|
3431
|
-
res.writeHead(409, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3432
|
-
res.end(payload);
|
|
3433
|
-
return;
|
|
3434
|
-
}
|
|
3435
|
-
// The caller-side receipt correlation: a message shaped as a
|
|
3436
|
-
// receipt resolves the task it echoes, whichever wait semantics
|
|
3437
|
-
// carried it here. Correlation is bookkeeping only — the message
|
|
3438
|
-
// steers on exactly as before.
|
|
3439
|
-
settleAndAnnounce(message);
|
|
3440
|
-
// gh#45 CLI mailbox (wire face): a delivery targeting a DEPOSITED
|
|
3441
|
-
// CLI handle never resolves a session and never wakes — it lands
|
|
3442
|
-
// in the deposit's mailbox and answers delivered. Placement here
|
|
3443
|
-
// keeps the idempotency claim (above) exactly as for sessions.
|
|
3444
|
-
const cliRow = cliDeposits.get(team);
|
|
3445
|
-
if (cliRow !== undefined) {
|
|
3446
|
-
cliDeposits.mail(team, caller, message);
|
|
3447
|
-
recordActivity('in', team, caller, true);
|
|
3448
|
-
logger.info(`a2a: cli mailbox delivery for ${team} (from ${caller})`);
|
|
3449
|
-
const payload = JSON.stringify({ routed: true, delivered: true, receipt: 'mailbox', team, task_id: taskId, task_status: 'TASK_STATE_COMPLETED', note: `delivered to the mailbox of ${team} — the CLI polls while online` });
|
|
3450
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3451
|
-
res.end(payload);
|
|
3452
|
-
return;
|
|
3453
|
-
}
|
|
3454
|
-
// Inbound routing is foreground demand: boot prewarm yields for a
|
|
3455
|
-
// quiet window around it.
|
|
3456
|
-
lastWakeDemandAt = Date.now();
|
|
3457
|
-
// wait:false delivers without the final-reply hold: steer resolves
|
|
3458
|
-
// synchronously inside routeIntoAgentFor, so by the time the agent
|
|
3459
|
-
// lookup settles the message is already in — answer delivered and
|
|
3460
|
-
// let the receipt contract carry the reply.
|
|
3461
|
-
if (noWait) {
|
|
3462
|
-
// Issue #41 companion fix: the no-wait inbound branch resolved
|
|
3463
|
-
// only bare session teams — a canvas team delivery (wait:false)
|
|
3464
|
-
// fell through to the cold tier and answered a bogus
|
|
3465
|
-
// no-match although a live member existed. Same resolution as
|
|
3466
|
-
// the waiting face (routeIntoAgent), and likewise NO initiator
|
|
3467
|
-
// skip: the wire face delivers faithfully to the first live
|
|
3468
|
-
// member, whoever the caller is.
|
|
3469
|
-
const agent = resolveAgentForTeam(team) ?? canvasLiveAgent(parseCanvasTeamName(team));
|
|
3470
|
-
const wake = agent !== undefined ? undefined : wakeColdTeam(team);
|
|
3471
|
-
const deliver = (target) => {
|
|
3472
|
-
const from = caller === '' ? 'an unknown node' : caller;
|
|
3473
|
-
// The receipt target: the caller's callback address when it
|
|
3474
|
-
// supplied one (a session node team — wake-on-route covers a
|
|
3475
|
-
// cold one), else the caller label as before.
|
|
3476
|
-
const receiptTarget = callback !== '' ? callback : from;
|
|
3477
|
-
try {
|
|
3478
|
-
// The receipt header carries the task id: the target echoes
|
|
3479
|
-
// it verbatim in "[A2A receipt] task <id> ...", closing the
|
|
3480
|
-
// correlation loop with the caller's own route result.
|
|
3481
|
-
// F1' (consumed-probe prior-running): capture the pre-steer
|
|
3482
|
-
// status first — a target that was already running (its own
|
|
3483
|
-
// turn / a brand-new session's activation window) reads
|
|
3484
|
-
// `running` after the steer whether or not our wake was
|
|
3485
|
-
// latched, so the post-steer read alone would answer a false
|
|
3486
|
-
// consumed:true and strand the message.
|
|
3487
|
-
const runningBeforeSteer = probeConsumption(target, taskId);
|
|
3488
|
-
steerRelay(target, `[A2A direct] (task ${taskId}) ${fromClause(from)} (routed to ${team}) sent:\n\n${message}\n\n(When done, route your outcome back with one call — a2a_route { team: "${receiptTarget}", message: "[A2A receipt] task ${taskId} <one-line outcome>" }.)`, from === 'an unknown node' ? undefined : from);
|
|
3489
|
-
recordActivity('in', team, caller, true);
|
|
3490
|
-
armReceiptAutosend(target, taskId, receiptTarget, message);
|
|
3491
|
-
// v0.5.23 (async-stall): "delivered" only proves the steer call
|
|
3492
|
-
// returned — not that a turn started. An idle-phase steer wakes
|
|
3493
|
-
// the driver, but a maintenance/abort window latches the wake for
|
|
3494
|
-
// replay that may never fire (defect t-mt6nd0sq-hxuhj6: seven
|
|
3495
|
-
// delivered:true routes with zero log growth). Probe the driver
|
|
3496
|
-
// shortly after: running means the message is being consumed;
|
|
3497
|
-
// still-idle means the wake was latched or dropped — surface that
|
|
3498
|
-
// honestly and arm a delayed nudge retry below.
|
|
3499
|
-
// F1': a prior-running target never trusts the single
|
|
3500
|
-
// post-steer read — answer consumed:false conservatively and
|
|
3501
|
-
// arm the nudge (a false negative costs one harmless nudge; a
|
|
3502
|
-
// false positive strands the delivered message).
|
|
3503
|
-
// F2' (receipt-settlement): the delivery is born into the
|
|
3504
|
-
// ledger — an inbound row owes a receipt only this node can
|
|
3505
|
-
// produce, and before this row existed the nudge's isPending
|
|
3506
|
-
// gate no-op'd for exactly the deliveries that stall (08-30:
|
|
3507
|
-
// zero ledger records for latched activations). Arming is
|
|
3508
|
-
// unconditional now; settlement evidence disarms in the
|
|
3509
|
-
// callback.
|
|
3510
|
-
const consumedProbe = runningBeforeSteer ? false : probeConsumption(target, taskId);
|
|
3511
|
-
// F5 (settlement integrity): the debt gate — a process-label
|
|
3512
|
-
// caller cannot receive receipts, so this delivery is born
|
|
3513
|
-
// fire-and-forget (receiptExpected:false, answered as
|
|
3514
|
-
// receipt:"none") instead of manufacturing a debt nobody can
|
|
3515
|
-
// settle. The row itself stays: stall visibility and the
|
|
3516
|
-
// nudge still apply to the TARGET side.
|
|
3517
|
-
const receiptExpectation = receiptExpectationOf(receiptTarget);
|
|
3518
|
-
taskLedger.trackInbound(taskId, team, receiptTarget, contextId === '' ? undefined : contextId, receiptExpectation !== 'process-label', briefOf(message));
|
|
3519
|
-
armAsyncNudge(target, team, taskId, config.asyncNudgeDelayMs);
|
|
3520
|
-
const payload = JSON.stringify({
|
|
3521
|
-
routed: true,
|
|
3522
|
-
delivered: true,
|
|
3523
|
-
team,
|
|
3524
|
-
session,
|
|
3525
|
-
task_id: taskId,
|
|
3526
|
-
context_id: contextId,
|
|
3527
|
-
task_status: 'TASK_STATE_DELIVERED',
|
|
3528
|
-
artifacts: [],
|
|
3529
|
-
consumed: consumedProbe,
|
|
3530
|
-
receipt: receiptExpectation === 'process-label' ? 'none' : 'routed',
|
|
3531
|
-
});
|
|
3532
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3533
|
-
res.end(payload);
|
|
3534
|
-
}
|
|
3535
|
-
catch (error) {
|
|
3536
|
-
recordActivity('in', team, caller, false);
|
|
3537
|
-
const payload = JSON.stringify({ error: `The DSH session rejected the message: ${String(error)}`, code: -32000, team, task_id: taskId, task_status: 'TASK_STATE_FAILED' });
|
|
3538
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3539
|
-
res.end(payload);
|
|
3540
|
-
}
|
|
3541
|
-
};
|
|
3542
|
-
if (agent !== undefined) {
|
|
3543
|
-
deliver(agent);
|
|
3544
|
-
return;
|
|
3545
|
-
}
|
|
3546
|
-
if (wake?.kind === 'flight') {
|
|
3547
|
-
void wake.flight.then(deliver, (error) => {
|
|
3548
|
-
recordActivity('in', team, caller, false);
|
|
3549
|
-
const payload = JSON.stringify({ error: `waking the session for team "${team}" failed: ${String(error)}`, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
|
|
3550
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3551
|
-
res.end(payload);
|
|
3552
|
-
});
|
|
3553
|
-
return;
|
|
3554
|
-
}
|
|
3555
|
-
// Remaining candidates need one async classification (the
|
|
3556
|
-
// native-teams bridge), so the tail answers inside an IIFE.
|
|
3557
|
-
void (async () => {
|
|
3558
|
-
// Native-teams inbound bridge (opt-in): prepare BEFORE answering
|
|
3559
|
-
// delivered — claim, seam, and parent initiator are everything
|
|
3560
|
-
// that can fail fast, and a phantom dispatch must never answer
|
|
3561
|
-
// success. Past prepare, the round fires detached (noWait means
|
|
3562
|
-
// delivery, not settlement); its late outcome corrects the
|
|
3563
|
-
// activity ring and the log.
|
|
3564
|
-
const standardMiss = () => {
|
|
3565
|
-
if (team === config.team) {
|
|
3566
|
-
const live = liveAgent();
|
|
3567
|
-
if (live !== undefined) {
|
|
3568
|
-
// Self-route refusal (mirrors the sync face): a caller
|
|
3569
|
-
// naming the very agent the fallback resolves to must
|
|
3570
|
-
// get an honest failure, never its own dispatch echoed
|
|
3571
|
-
// home plus an auto-receipt for work nobody did.
|
|
3572
|
-
if (callerIsAgent(live, caller)) {
|
|
3573
|
-
recordActivity('in', team, caller, false);
|
|
3574
|
-
const payload = JSON.stringify({ error: selfRouteText(team), code: -32000, team, task_status: 'TASK_STATE_FAILED' });
|
|
3575
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3576
|
-
res.end(payload);
|
|
3577
|
-
return;
|
|
3578
|
-
}
|
|
3579
|
-
deliver(live);
|
|
3580
|
-
return;
|
|
3581
|
-
}
|
|
3582
|
-
}
|
|
3583
|
-
const payload = JSON.stringify({ error: wake?.kind === 'no-face' ? noWakeFaceText(team) : wake?.kind === 'archived' ? archivedColdMatchText(team) : noColdMatchText(team), code: -32000, team, task_status: 'TASK_STATE_FAILED' });
|
|
3584
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3585
|
-
res.end(payload);
|
|
3586
|
-
};
|
|
3587
|
-
const prepared = await nativeTeamsPrepare(team);
|
|
3588
|
-
if (!prepared.ok) {
|
|
3589
|
-
if (prepared.reason === 'error') {
|
|
3590
|
-
recordActivity('in', team, caller, false);
|
|
3591
|
-
const payload = JSON.stringify({ error: prepared.error, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
|
|
3592
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3593
|
-
res.end(payload);
|
|
3594
|
-
return;
|
|
3595
|
-
}
|
|
3596
|
-
standardMiss();
|
|
3597
|
-
return;
|
|
3598
|
-
}
|
|
3599
|
-
recordActivity('in', team, caller, true);
|
|
3600
|
-
void nativeTeamsRound(prepared, team, caller, message, taskId)
|
|
3601
|
-
.then(outcome => {
|
|
3602
|
-
if (!outcome.ok) {
|
|
3603
|
-
recordActivity('in', team, caller, false);
|
|
3604
|
-
logger.warn(`a2a: detached native-teams round for "${team}" failed: ${outcome.error}`);
|
|
3605
|
-
}
|
|
3606
|
-
// W7 slice-2 hook 2: only a genuinely settled detached round
|
|
3607
|
-
// records an outcome. The deadline's settled:false
|
|
3608
|
-
// projection is a placeholder, not a product — the same
|
|
3609
|
-
// ruling as hook 1's DELIVERED skip; placeholder prose never
|
|
3610
|
-
// enters the ledger a replay could adopt from.
|
|
3611
|
-
if (taskId !== '') {
|
|
3612
|
-
if (!outcome.ok)
|
|
3613
|
-
idempotencyStore.recordOutcome(taskId, { status: 'failed', error: outcome.error });
|
|
3614
|
-
else if (outcome.settled)
|
|
3615
|
-
idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: outcome.reply });
|
|
3616
|
-
}
|
|
3617
|
-
});
|
|
3618
|
-
const payload = JSON.stringify({ routed: true, delivered: true, team, session, task_id: taskId, context_id: contextId, task_status: 'TASK_STATE_DELIVERED', artifacts: [], consumed: false, bridge: 'native-teams' });
|
|
3619
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3620
|
-
res.end(payload);
|
|
3621
|
-
})();
|
|
3622
|
-
return;
|
|
3623
|
-
}
|
|
3624
|
-
void routeIntoAgent(team, message, caller, taskId).then((outcome) => {
|
|
3625
|
-
recordActivity('in', team, caller, outcome.ok);
|
|
3626
|
-
// W7 slice-2 hook 1: record the settled outcome on the claim row.
|
|
3627
|
-
// Ruling: a non-COMPLETED status (TASK_STATE_DELIVERED — the
|
|
3628
|
-
// bridge-round deadline placeholder, ABORTED_WAIT likewise) is
|
|
3629
|
-
// NOT a settled outcome, and neither is a host-authored
|
|
3630
|
-
// placeholder on the reply channel (flush timeout, dead session)
|
|
3631
|
-
// — the row stays pending, because an adopted replay must never
|
|
3632
|
-
// settle a node on placeholder text (the exact hazard W7 gap B
|
|
3633
|
-
// closed, barred from resurrecting through the outcome ledger).
|
|
3634
|
-
if (taskId !== '') {
|
|
3635
|
-
if (!outcome.ok) {
|
|
3636
|
-
idempotencyStore.recordOutcome(taskId, { status: 'failed', error: outcome.error });
|
|
3637
|
-
}
|
|
3638
|
-
else if (outcome.placeholder === true) {
|
|
3639
|
-
// placeholder prose never enters the ledger
|
|
3640
|
-
}
|
|
3641
|
-
else if (outcome.status === undefined || outcome.status === 'TASK_STATE_COMPLETED') {
|
|
3642
|
-
idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: outcome.reply });
|
|
3643
|
-
}
|
|
3644
|
-
}
|
|
3645
|
-
const payload = outcome.ok
|
|
3646
|
-
? JSON.stringify({
|
|
3647
|
-
routed: true,
|
|
3648
|
-
team,
|
|
3649
|
-
session,
|
|
3650
|
-
result: { text: outcome.reply },
|
|
3651
|
-
task_id: taskId,
|
|
3652
|
-
context_id: contextId,
|
|
3653
|
-
task_status: outcome.status ?? 'TASK_STATE_COMPLETED',
|
|
3654
|
-
artifacts: [],
|
|
3655
|
-
// The bridge marker rides the wire so the caller's
|
|
3656
|
-
// trackOwedTask skips a receipt-less native round — without
|
|
3657
|
-
// it, a cross-node unsettled round books an unpayable owed
|
|
3658
|
-
// row and re-creates the TASK_CAP backlog.
|
|
3659
|
-
...(outcome.bridge !== undefined ? { bridge: outcome.bridge } : {}),
|
|
3660
|
-
})
|
|
3661
|
-
: JSON.stringify({ error: outcome.error, code: -32000, team, task_id: taskId, task_status: 'TASK_STATE_FAILED' });
|
|
3662
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3663
|
-
res.end(payload);
|
|
3664
|
-
});
|
|
3665
|
-
});
|
|
3666
|
-
},
|
|
3667
|
-
}), 'a2a: direct route endpoint');
|
|
3668
|
-
// 0.6.15: the read-only peer log face — a peer asks for THIS node's
|
|
3669
|
-
// session transcript tail (or, without id, the readable-session
|
|
3670
|
-
// listing) so a remote workbench can show "what has this node been
|
|
3671
|
-
// talking about". Admission is a2aCallerRefusal — exactly the rule
|
|
3672
|
-
// /a2a/direct applies to message delivery: caller_session required,
|
|
3673
|
-
// zone/id8 callers judged on shared teams. Read semantics are the
|
|
3674
|
-
// local /__dsh_a2a/session-log face verbatim (artifact existence is
|
|
3675
|
-
// the only gate; archived and never-joined sessions stay readable;
|
|
3676
|
-
// caps mirror session-transcript.ts). Nothing here steers a session,
|
|
3677
|
-
// claims a task, or writes state — the only side effects are the
|
|
3678
|
-
// activity-ring record and the admission path's background
|
|
3679
|
-
// directory learn.
|
|
3680
|
-
ctx.effect(() => webServer.register({
|
|
3681
|
-
kind: 'exact',
|
|
3682
|
-
path: '/a2a/log',
|
|
3683
|
-
handler: async (req, res) => {
|
|
3684
|
-
const send = (status, body) => {
|
|
3685
|
-
const payload = JSON.stringify(body);
|
|
3686
|
-
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3687
|
-
res.end(payload);
|
|
3688
|
-
};
|
|
3689
|
-
if (req.method !== 'GET') {
|
|
3690
|
-
send(405, { ok: false, error: 'GET is the only method on this face' });
|
|
3691
|
-
return;
|
|
3692
|
-
}
|
|
3693
|
-
// V2 (gh#7, S1): the read-only log face rides the same apiKey gate
|
|
3694
|
-
// as delivery — a configured key must be presented here too.
|
|
3695
|
-
if (config.apiKey !== '') {
|
|
3696
|
-
const presented = String(req.headers['x-api-key'] ?? '');
|
|
3697
|
-
if (!secretEqual(presented, config.apiKey)) {
|
|
3698
|
-
send(401, { ok: false, error: 'unauthorized: X-API-Key required (apiKey is configured)', code: -32000 });
|
|
3699
|
-
return;
|
|
3700
|
-
}
|
|
3701
|
-
}
|
|
3702
|
-
const url = new URL(req.url ?? '/', 'http://x');
|
|
3703
|
-
const caller = url.searchParams.get('caller_session') ?? '';
|
|
3704
|
-
const id = url.searchParams.get('id') ?? '';
|
|
3705
|
-
const limitArg = Number(url.searchParams.get('limit') ?? TRANSCRIPT_DEFAULT_LIMIT);
|
|
3706
|
-
const limit = Number.isFinite(limitArg) ? Math.max(1, Math.min(TRANSCRIPT_MAX_LIMIT, Math.floor(limitArg))) : TRANSCRIPT_DEFAULT_LIMIT;
|
|
3707
|
-
const refusal = a2aCallerRefusal(caller);
|
|
3708
|
-
if (refusal !== undefined) {
|
|
3709
|
-
recordActivity('in', 'log', caller === '' ? 'anonymous' : caller, false);
|
|
3710
|
-
send(200, { ok: false, error: refusal, code: -32000 });
|
|
3711
|
-
return;
|
|
3712
|
-
}
|
|
3713
|
-
// gh#9+gh#10: the log face reuses the exact OriginClaim
|
|
3714
|
-
// verification (§8.3) — same window semantics as delivery.
|
|
3715
|
-
const logClaim = await verifyOriginClaim(req);
|
|
3716
|
-
if (!logClaim.ok) {
|
|
3717
|
-
if (logClaim.reason !== 'absent' || config.originClaimWindow === false) {
|
|
3718
|
-
recordActivity('in', 'log', caller, false);
|
|
3719
|
-
send(200, { ok: false, error: `untrusted-origin: OriginClaim verification failed (${logClaim.reason}) — present a signed envelope or upgrade your node`, code: -32000 });
|
|
3720
|
-
return;
|
|
3721
|
-
}
|
|
3722
|
-
legacyInboundCount += 1;
|
|
3723
|
-
}
|
|
3724
|
-
recordActivity('in', 'log', caller, true);
|
|
3725
|
-
if (id.includes('/') || id.includes('\\') || id.includes('..')) {
|
|
3726
|
-
send(200, { ok: false, error: 'id is required' });
|
|
3727
|
-
return;
|
|
3728
|
-
}
|
|
3729
|
-
if (id === '') {
|
|
3730
|
-
try {
|
|
3731
|
-
const rows = scanSessionLogList();
|
|
3732
|
-
send(200, { ok: true, count: rows.length, sessions: rows.slice(0, 50) });
|
|
3733
|
-
}
|
|
3734
|
-
catch (error) {
|
|
3735
|
-
send(200, { ok: false, error: `transcript list failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
3736
|
-
}
|
|
3737
|
-
return;
|
|
3738
|
-
}
|
|
3739
|
-
try {
|
|
3740
|
-
const tail = readSessionLogTail(id, limit);
|
|
3741
|
-
if (!tail.ok)
|
|
3742
|
-
send(200, { ok: false, error: tail.error });
|
|
3743
|
-
else
|
|
3744
|
-
send(200, { ok: true, id: tail.id, count: tail.messages.length, messages: tail.messages });
|
|
3745
|
-
}
|
|
3746
|
-
catch (error) {
|
|
3747
|
-
send(200, { ok: false, error: `transcript failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
3748
|
-
}
|
|
3749
|
-
},
|
|
3750
|
-
}), 'a2a: peer log face endpoint');
|
|
3751
|
-
});
|
|
3752
|
-
whenWebServerSettled((webServer) => {
|
|
3753
|
-
// W7 slice 2: the S1 outcome-retrieval surface — a READ-ONLY lookup of
|
|
3754
|
-
// one claimed task's settled outcome. The body carries ONLY the task id
|
|
3755
|
-
// and the submit payload's fingerprint (peerPayloadFingerprint, the
|
|
3756
|
-
// gate's own shared implementation): the fingerprint match is the
|
|
3757
|
-
// authorization, and the fan-out probe never leaks the original message
|
|
3758
|
-
// to peers that never saw it. Constant-200 semantics — a query NEVER
|
|
3759
|
-
// produces a wire error code (the frozen -32002/-32003 vocabulary stays
|
|
3760
|
-
// submit-only), never claims, never steers, never enters a session: the
|
|
3761
|
-
// gate's "verdict before steer" has its mirror in "a query executes
|
|
3762
|
-
// nothing". Malformed fields degrade to unknown-task rather than
|
|
3763
|
-
// guessing; an unparseable body answers 400 like /a2a/direct.
|
|
3764
|
-
ctx.effect(() => webServer.register({
|
|
3765
|
-
kind: 'exact',
|
|
3766
|
-
path: '/a2a/query',
|
|
3767
|
-
handler: (req, res) => {
|
|
3768
|
-
const chunks = [];
|
|
3769
|
-
let size = 0;
|
|
3770
|
-
let overflowed = false;
|
|
3771
|
-
req.on('data', (chunk) => {
|
|
3772
|
-
if (overflowed)
|
|
3773
|
-
return;
|
|
3774
|
-
const next = size + chunk.length;
|
|
3775
|
-
if (next > 65_536) {
|
|
3776
|
-
overflowed = true;
|
|
3777
|
-
chunks.length = 0;
|
|
3778
|
-
return;
|
|
3779
|
-
}
|
|
3780
|
-
size = next;
|
|
3781
|
-
chunks.push(chunk);
|
|
3782
|
-
});
|
|
3783
|
-
req.on('error', () => {
|
|
3784
|
-
if (!res.headersSent) {
|
|
3785
|
-
res.writeHead(400);
|
|
3786
|
-
res.end();
|
|
3787
|
-
}
|
|
3788
|
-
});
|
|
3789
|
-
req.on('end', () => {
|
|
3790
|
-
if (overflowed) {
|
|
3791
|
-
if (!res.writableEnded && !res.headersSent) {
|
|
3792
|
-
const payload = JSON.stringify({ error: 'payload too large', code: WIRE_ERROR_PAYLOAD_TOO_LARGE });
|
|
3793
|
-
res.writeHead(413, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3794
|
-
res.end(payload);
|
|
3795
|
-
}
|
|
3796
|
-
return;
|
|
3797
|
-
}
|
|
3798
|
-
let body;
|
|
3799
|
-
try {
|
|
3800
|
-
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
3801
|
-
}
|
|
3802
|
-
catch {
|
|
3803
|
-
const payload = JSON.stringify({ error: 'malformed body', code: -32000 });
|
|
3804
|
-
res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3805
|
-
res.end(payload);
|
|
3806
|
-
return;
|
|
3807
|
-
}
|
|
3808
|
-
const taskId = typeof body.task_id === 'string' ? body.task_id : '';
|
|
3809
|
-
const fingerprint = typeof body.fingerprint === 'string' ? body.fingerprint : '';
|
|
3810
|
-
const verdict = taskId === '' || fingerprint === ''
|
|
3811
|
-
? { found: false, reason: 'unknown-task' }
|
|
3812
|
-
: idempotencyStore.query(taskId, fingerprint);
|
|
3813
|
-
const payload = JSON.stringify(verdict.found
|
|
3814
|
-
? verdict.status === 'pending'
|
|
3815
|
-
? { found: true, status: 'pending', task_id: taskId }
|
|
3816
|
-
: verdict.status === 'completed'
|
|
3817
|
-
? {
|
|
3818
|
-
found: true,
|
|
3819
|
-
status: 'completed',
|
|
3820
|
-
reply: verdict.reply,
|
|
3821
|
-
settled_at: new Date(verdict.settledAt).toISOString(),
|
|
3822
|
-
task_id: taskId,
|
|
3823
|
-
...(verdict.truncated === true ? { truncated: true } : {}),
|
|
3824
|
-
}
|
|
3825
|
-
: {
|
|
3826
|
-
found: true,
|
|
3827
|
-
status: 'failed',
|
|
3828
|
-
error: verdict.error,
|
|
3829
|
-
settled_at: new Date(verdict.settledAt).toISOString(),
|
|
3830
|
-
task_id: taskId,
|
|
3831
|
-
...(verdict.truncated === true ? { truncated: true } : {}),
|
|
3832
|
-
}
|
|
3833
|
-
: { found: false, reason: verdict.reason, task_id: taskId });
|
|
3834
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3835
|
-
res.end(payload);
|
|
3836
|
-
});
|
|
3837
|
-
},
|
|
3838
|
-
}), 'a2a: query endpoint');
|
|
2532
|
+
registerWireFaces(webServer, {
|
|
2533
|
+
ctx,
|
|
2534
|
+
logger,
|
|
2535
|
+
config,
|
|
2536
|
+
cliDeposits,
|
|
2537
|
+
canvasStore,
|
|
2538
|
+
sessionNodes,
|
|
2539
|
+
joinedSessions,
|
|
2540
|
+
idempotencyStore,
|
|
2541
|
+
verifyOriginClaim,
|
|
2542
|
+
callerLinkedToThisHost,
|
|
2543
|
+
recordActivity,
|
|
2544
|
+
bumpLegacyInbound: () => { legacyInboundCount += 1; },
|
|
2545
|
+
secretEqual,
|
|
2546
|
+
a2aCallerRefusal,
|
|
2547
|
+
scanSessionLogList,
|
|
2548
|
+
readSessionLogTail,
|
|
2549
|
+
taskLedger,
|
|
2550
|
+
refuseIfFlooded,
|
|
2551
|
+
settleAndAnnounce,
|
|
2552
|
+
touchWakeDemand: () => { lastWakeDemandAt = Date.now(); },
|
|
2553
|
+
session,
|
|
2554
|
+
resolveAgentForTeam,
|
|
2555
|
+
parseCanvasTeamName,
|
|
2556
|
+
canvasLiveAgent,
|
|
2557
|
+
wakeColdTeam,
|
|
2558
|
+
probeConsumption,
|
|
2559
|
+
steerRelay,
|
|
2560
|
+
fromClause,
|
|
2561
|
+
armReceiptAutosend,
|
|
2562
|
+
receiptExpectationOf,
|
|
2563
|
+
armAsyncNudge,
|
|
2564
|
+
liveAgent,
|
|
2565
|
+
callerIsAgent,
|
|
2566
|
+
selfRouteText,
|
|
2567
|
+
noWakeFaceText,
|
|
2568
|
+
archivedColdMatchText,
|
|
2569
|
+
noColdMatchText,
|
|
2570
|
+
nativeTeamsPrepare,
|
|
2571
|
+
nativeTeamsRound,
|
|
2572
|
+
routeIntoAgent,
|
|
2573
|
+
});
|
|
3839
2574
|
});
|
|
3840
2575
|
whenWebServerSettled((webServer) => {
|
|
3841
2576
|
// Task-ledger control: explicit caller-side abandonment of one owed
|
|
@@ -4170,6 +2905,18 @@ ${message}`;
|
|
|
4170
2905
|
async function fetchPeerCard(peer) {
|
|
4171
2906
|
return settlePeerCard(peer, await client.fetchCardDetail(peer));
|
|
4172
2907
|
}
|
|
2908
|
+
/**
|
|
2909
|
+
* Apply-scope shared card cache: every caller (teams listing, route
|
|
2910
|
+
* candidate walks, panel sweeps) shares one TTL window per URL, so a
|
|
2911
|
+
* poll-happy consumer cannot re-fetch a peer's card — or re-punish a dead
|
|
2912
|
+
* one — more than once per window. Cards live for days and re-sign at
|
|
2913
|
+
* TTL/4, so a 60s serve window is semantically safe. Negative results
|
|
2914
|
+
* (unreachable/invalid) cache for a shorter window so a restarting peer
|
|
2915
|
+
* reappears quickly. Scoring stays with the real network fetch only: a
|
|
2916
|
+
* cache hit never re-scores, which is what keeps the debounced peer-store
|
|
2917
|
+
* writes rare. Concurrent callers for one URL join a single in-flight
|
|
2918
|
+
* fetch (single-flight) instead of racing N requests.
|
|
2919
|
+
*/
|
|
4173
2920
|
const cardCache = new Map();
|
|
4174
2921
|
const cardCacheInFlight = new Map();
|
|
4175
2922
|
/**
|
|
@@ -4307,7 +3054,16 @@ ${message}`;
|
|
|
4307
3054
|
function cachedCardFetch(url) {
|
|
4308
3055
|
const hit = cardCache.get(url);
|
|
4309
3056
|
const now = Date.now();
|
|
4310
|
-
if (hit !== undefined &&
|
|
3057
|
+
if (hit !== undefined && hit.card !== undefined && hit.missAt !== undefined) {
|
|
3058
|
+
// Kept through a miss (PR#96 review P2-2 round 2): serve the old card
|
|
3059
|
+
// for the negative window — a dead peer is refetched once per window,
|
|
3060
|
+
// the same cadence as a peer that never had a card — and never past the
|
|
3061
|
+
// card's own expiresAt: an old key gets no extra trust, and a dead peer
|
|
3062
|
+
// leaves the directory when its card lapses.
|
|
3063
|
+
if (now - hit.missAt < config.cardCacheNegativeTtlMs && hit.card.expiresAt > now)
|
|
3064
|
+
return Promise.resolve(hit.card);
|
|
3065
|
+
}
|
|
3066
|
+
else if (hit !== undefined && now - hit.at < (hit.card !== undefined ? config.cardCacheTtlMs : config.cardCacheNegativeTtlMs)) {
|
|
4311
3067
|
return Promise.resolve(hit.card);
|
|
4312
3068
|
}
|
|
4313
3069
|
const inFlight = cardCacheInFlight.get(url);
|
|
@@ -4315,6 +3071,17 @@ ${message}`;
|
|
|
4315
3071
|
return inFlight;
|
|
4316
3072
|
const fetch = fetchPeerCard(url)
|
|
4317
3073
|
.then(card => {
|
|
3074
|
+
// P2-2: a miss must not blank the mirror for callers joining this
|
|
3075
|
+
// single flight nor for later readers — keep the last good entry,
|
|
3076
|
+
// stamped missAt so the window above bounds the retries; a card past
|
|
3077
|
+
// its own expiresAt is not kept (undefined, honest exit).
|
|
3078
|
+
if (card === undefined) {
|
|
3079
|
+
const old = cardCache.get(url);
|
|
3080
|
+
if (old !== undefined && old.card !== undefined && old.card.expiresAt > Date.now()) {
|
|
3081
|
+
cardCache.set(url, { at: old.at, card: old.card, missAt: Date.now() });
|
|
3082
|
+
return old.card;
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
4318
3085
|
cardCache.set(url, { at: Date.now(), card });
|
|
4319
3086
|
return card;
|
|
4320
3087
|
})
|
|
@@ -4439,7 +3206,7 @@ ${message}`;
|
|
|
4439
3206
|
allowlist: {
|
|
4440
3207
|
type: 'object',
|
|
4441
3208
|
additionalProperties: true,
|
|
4442
|
-
description: 'The owner-curated teamJoinAllowlist
|
|
3209
|
+
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).',
|
|
4443
3210
|
},
|
|
4444
3211
|
teamScopeRouting: { type: 'boolean' },
|
|
4445
3212
|
note: { type: 'string', description: 'What join does and does not gate, in one sentence.' },
|
|
@@ -5177,11 +3944,12 @@ ${message}`;
|
|
|
5177
3944
|
// node team when it has joined, else the node label), so the receiver
|
|
5178
3945
|
// can answer with one a2a_route instead of an unroutable display label.
|
|
5179
3946
|
a2aJoinGateRefusal(agent === undefined ? undefined : { agent });
|
|
5180
|
-
// S3 (team-scoped routing,
|
|
5181
|
-
//
|
|
5182
|
-
// declared membership in the target team before it
|
|
5183
|
-
// and a caller with no teams at all has no network —
|
|
5184
|
-
// is, by the same ruling, an unjoined one. Host-side
|
|
3947
|
+
// S3 (team-scoped routing, phase-2 default ON per the 2026-09-01 owner
|
|
3948
|
+
// ruling; set false to restore the open outbound seam): a session
|
|
3949
|
+
// caller must have declared membership in the target team before it
|
|
3950
|
+
// may address it, and a caller with no teams at all has no network —
|
|
3951
|
+
// a teamless node is, by the same ruling, an unjoined one. Host-side
|
|
3952
|
+
// calls (no agent)
|
|
5185
3953
|
// and the initiator face stay exempt, like the join gate; discovery
|
|
5186
3954
|
// (a2a_teams) stays open so a teamless node can still see what to join.
|
|
5187
3955
|
if (config.teamScopeRouting && agent !== undefined) {
|
|
@@ -5194,13 +3962,48 @@ ${message}`;
|
|
|
5194
3962
|
if (!initiator) {
|
|
5195
3963
|
const callerTeams = teamMemberships.teamsOf(scopeCaller);
|
|
5196
3964
|
const team = String(args.team ?? '').trim();
|
|
5197
|
-
|
|
3965
|
+
// gh#71: the SHARED-CANVAS basis and the warm cache's peer-org
|
|
3966
|
+
// basis (an org entry naming BOTH the caller and the target
|
|
3967
|
+
// handle) count as declared here too — the same link the
|
|
3968
|
+
// admission core honors on the receiving side; without this the
|
|
3969
|
+
// tool face refused routes the canvas itself had authorized.
|
|
3970
|
+
// Canvas members mix two shapes: LOCAL sessions sit by session
|
|
3971
|
+
// id, cross-host members by zone/id8 handle — match either.
|
|
3972
|
+
const callerHandle = `${config.team}/${id8(scopeCaller)}`;
|
|
3973
|
+
// gh#45 末修 (66abc2cf defect): a CANVAS GROUP NAME target
|
|
3974
|
+
// (`<team>/canvas/<name>`) never matches member handles — a
|
|
3975
|
+
// whole-group dispatch from a group member was refused. The
|
|
3976
|
+
// group's own team handle (canvasTeamOf(name)) is the match
|
|
3977
|
+
// (the caller sits in the very group being addressed).
|
|
3978
|
+
const sharedCanvas = canvasStore.list().some(name => {
|
|
3979
|
+
const members = canvasStore.membersOf(name);
|
|
3980
|
+
const inGroup = members.includes(scopeCaller) || members.includes(callerHandle);
|
|
3981
|
+
return inGroup ? members.includes(team) || canvasTeamOf(name) === team : false;
|
|
3982
|
+
});
|
|
3983
|
+
let orgShared = false;
|
|
3984
|
+
for (const cached of cardCache.values()) {
|
|
3985
|
+
const org = cached.card?.canvasOrg;
|
|
3986
|
+
if (!Array.isArray(org))
|
|
3987
|
+
continue;
|
|
3988
|
+
for (const e of org) {
|
|
3989
|
+
if (typeof e.team !== 'string' || e.team === '' || !Array.isArray(e.members))
|
|
3990
|
+
continue;
|
|
3991
|
+
const inOrg = e.members.includes(scopeCaller) || e.members.includes(callerHandle);
|
|
3992
|
+
if (inOrg && (e.members.includes(team) || e.team === team)) {
|
|
3993
|
+
orgShared = true;
|
|
3994
|
+
break;
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
if (orgShared)
|
|
3998
|
+
break;
|
|
3999
|
+
}
|
|
4000
|
+
if (callerTeams.length === 0 && !sharedCanvas && !orgShared) {
|
|
5198
4001
|
return {
|
|
5199
4002
|
ok: false,
|
|
5200
4003
|
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)`,
|
|
5201
4004
|
};
|
|
5202
4005
|
}
|
|
5203
|
-
if (!callerTeams.includes(team)) {
|
|
4006
|
+
if (!callerTeams.includes(team) && !sharedCanvas && !orgShared) {
|
|
5204
4007
|
return {
|
|
5205
4008
|
ok: false,
|
|
5206
4009
|
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)`,
|
|
@@ -6218,8 +5021,13 @@ ${message}`;
|
|
|
6218
5021
|
// and learns its referrals, exactly like a discovery sweep would,
|
|
6219
5022
|
// while the outcome's stage names the failure for the report.
|
|
6220
5023
|
const card = settlePeerCard(url, outcome);
|
|
6221
|
-
if (card !== undefined)
|
|
5024
|
+
if (card !== undefined) {
|
|
5025
|
+
// gh#71 fix ①: a successful full-face fetch persists into the warm
|
|
5026
|
+
// cache — the probe is often the FIRST contact (no directory sweep
|
|
5027
|
+
// has run), and the state face's peerOrg reads exactly this cache.
|
|
5028
|
+
cardCache.set(url, { at: Date.now(), card });
|
|
6222
5029
|
return { url, reachable: true, ms, team: card.team };
|
|
5030
|
+
}
|
|
6223
5031
|
if (outcome.ok)
|
|
6224
5032
|
return { url, reachable: false, ms, error: 'dropped: the card lists this node back as its own referral' };
|
|
6225
5033
|
return outcome.stage === 'unreachable'
|