@nelsonlongxiang/dsh-open-a2a-net 0.6.27 → 0.6.29
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 +85 -14
- package/README_zh.md +81 -7
- package/assets/nexusDist/assets/index-BqepxJ7E.css +1 -0
- package/assets/nexusDist/assets/index-CTWH6QRh.js +9 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +624 -612
- package/cordis.patch.yml +21 -21
- package/lib/canvas-store.js +5 -1
- package/lib/cli-deposit.js +61 -12
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +744 -0
- package/lib/index.js +222 -756
- package/lib/peer-store.js +60 -4
- package/lib/recursion-guard.js +78 -0
- package/lib/runtime.js +1 -0
- package/lib/skill/SKILL.md +48 -11
- package/lib/skill/references/cli.md +109 -0
- package/lib/skill/references/discovery.md +15 -4
- package/lib/skill/references/membership.md +29 -6
- package/lib/skill/references/pitfalls.md +12 -4
- package/lib/skill/references/routing.md +18 -8
- package/lib/skill/references/setup.md +152 -0
- package/lib/task-ledger.js +6 -0
- package/lib/types/canvas-store.d.ts +5 -1
- package/lib/types/cli-deposit.d.ts +30 -1
- package/lib/types/faces/admin.d.ts +132 -0
- package/lib/types/index.d.ts +11 -3
- package/lib/types/peer-store.d.ts +24 -1
- package/lib/types/recursion-guard.d.ts +56 -0
- package/lib/types/runtime.d.ts +34 -0
- package/lib/types/task-ledger.d.ts +6 -0
- 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 -0
- 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/planning-card-text.ts +73 -0
- package/nexus-stage/src/planning-fit.ts +45 -0
- package/nexus-stage/src/planning-legend.ts +38 -0
- package/nexus-stage/src/planning-overlap.ts +134 -0
- package/nexus-stage/src/planning-relayout.ts +121 -0
- package/nexus-stage/src/planning-search.ts +168 -0
- package/nexus-stage/src/planning-view.ts +94 -119
- package/nexus-stage/src/planning.css +39 -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 +5 -1
- package/package.json +216 -214
- package/assets/nexusDist/assets/index-DszoLz8i.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
|
@@ -31,19 +31,17 @@ import { GroupStore } from "./group-store.js";
|
|
|
31
31
|
import { CanvasStore, isRemoteMemberRef, validRemoteMemberRef } 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 { refuseIfBudgetExhausted } from "./recursion-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,8 @@ 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, withinRouteBodyCap } from "./transport-caps.js";
|
|
53
|
+
import { registerAdminFaces } from "./faces/admin.js";
|
|
55
54
|
import { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
|
|
56
55
|
export { A2aClient } from "./a2a-client.js";
|
|
57
56
|
export { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
|
|
@@ -79,11 +78,13 @@ export const Config = s.object({
|
|
|
79
78
|
wakeReconcileBackoffBaseMs: s.number().default(5_000),
|
|
80
79
|
wakeReconcileMaxBackoffMs: s.number().default(600_000),
|
|
81
80
|
/**
|
|
82
|
-
* S4: the joinable-team curation list. A model calling
|
|
83
|
-
* only declare membership in a team
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* a
|
|
81
|
+
* S4 (gh#11 N7): the joinable-team curation list. A model calling
|
|
82
|
+
* a2a_team_join may only declare membership in a team with an EXACT
|
|
83
|
+
* entry here — trailing-`*` patterns are readable but authorize nothing
|
|
84
|
+
* (no mint rights: a pattern pre-authorizing every future name under it
|
|
85
|
+
* would let a model mint teams nobody created). Default empty: the
|
|
86
|
+
* owner's no-self-join ruling, institutionalized as default-deny; a
|
|
87
|
+
* refused join carries guidance instead of failing silently.
|
|
87
88
|
*/
|
|
88
89
|
teamJoinAllowlist: s.array(s.string()).default([]),
|
|
89
90
|
/**
|
|
@@ -365,10 +366,10 @@ export function apply(ctx, config) {
|
|
|
365
366
|
* @param res - the response for the malformed-body rejection.
|
|
366
367
|
* @param use - receives the parsed body.
|
|
367
368
|
*/
|
|
368
|
-
// Wire code for boundary-refused corrupted text
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
const WIRE_ERROR_TEXT_CORRUPTED = -
|
|
369
|
+
// Wire code for boundary-refused corrupted text (cluster-C error-code
|
|
370
|
+
// registry): -32004 belongs to the route verdict, -32005 to the client's
|
|
371
|
+
// DELIVERED-UNSETTLED family, so the corrupted-text guard owns -32006.
|
|
372
|
+
const WIRE_ERROR_TEXT_CORRUPTED = -32006;
|
|
372
373
|
/**
|
|
373
374
|
* Boundary guard for undecodable text (consumed by the readJsonBody
|
|
374
375
|
* guard): U+FFFD is produced by a lossy decoder upstream of us — the bytes
|
|
@@ -586,8 +587,18 @@ export function apply(ctx, config) {
|
|
|
586
587
|
if (teamAuditRing.length > 32)
|
|
587
588
|
teamAuditRing.splice(0, teamAuditRing.length - 32);
|
|
588
589
|
};
|
|
590
|
+
/**
|
|
591
|
+
* Recent routing activity for the network panel: a bounded ring of the
|
|
592
|
+
* latest inbound and outbound route outcomes, newest last. The state route
|
|
593
|
+
* serves a copy; the panel derives unread badges from new entries.
|
|
594
|
+
*/
|
|
589
595
|
const ACTIVITY_CAP = 20;
|
|
590
596
|
const recentActivity = [];
|
|
597
|
+
/**
|
|
598
|
+
* In-flight outbound routes: a route registers on dial and unregisters on
|
|
599
|
+
* settlement, so the panel can show collaboration in progress ("who is
|
|
600
|
+
* waiting on whom") — the state route serves a snapshot.
|
|
601
|
+
*/
|
|
591
602
|
const inFlightRoutes = new Map();
|
|
592
603
|
let inFlightSeq = 0;
|
|
593
604
|
const beginRoute = (team, peer) => {
|
|
@@ -755,9 +766,13 @@ export function apply(ctx, config) {
|
|
|
755
766
|
* the join consented to network reachability, so a route addressed to
|
|
756
767
|
* its team pays the wake rather than failing.
|
|
757
768
|
* @param team - the routed team name.
|
|
769
|
+
* @param skipId - the dispatching session's id (issue #41): a canvas
|
|
770
|
+
* team's cold tier skips the initiator — waking the author would loop the
|
|
771
|
+
* dispatch home. Bare session-team matches ignore it (an exact team match
|
|
772
|
+
* is the addressed target by definition).
|
|
758
773
|
* @returns the wake outcome: a flight, or the reason none was started.
|
|
759
774
|
*/
|
|
760
|
-
const wakeColdTeam = (team) => {
|
|
775
|
+
const wakeColdTeam = (team, skipId) => {
|
|
761
776
|
// F7 (wake-intent self-heal): a boot-time transient can prune the
|
|
762
777
|
// in-memory intent while joined.json still holds it (memory/file
|
|
763
778
|
// divergence — the live repro on 0.5.40). The persisted file is the
|
|
@@ -770,8 +785,9 @@ export function apply(ctx, config) {
|
|
|
770
785
|
aliasId = matchJoined();
|
|
771
786
|
}
|
|
772
787
|
// Canvas teams wake their first cold joined member (member order is
|
|
773
|
-
// the routing priority; an archived member never wakes
|
|
774
|
-
|
|
788
|
+
// the routing priority; an archived member never wakes; the initiator
|
|
789
|
+
// itself is skipped — issue #41, the self-route rule on canvas tiers).
|
|
790
|
+
const id = aliasId ?? canvasColdMemberId(parseCanvasTeamName(team), skipId);
|
|
775
791
|
// An archived session never wakes: archive is closure, not sleep — to
|
|
776
792
|
// the caller the team simply has no wakeable match.
|
|
777
793
|
if (id === undefined)
|
|
@@ -920,11 +936,19 @@ export function apply(ctx, config) {
|
|
|
920
936
|
const name = team.slice(prefix.length);
|
|
921
937
|
return canvasStore.hasTeam(name) ? name : undefined;
|
|
922
938
|
};
|
|
923
|
-
/**
|
|
924
|
-
|
|
939
|
+
/**
|
|
940
|
+
* The canvas team's first live member agent (member order = priority).
|
|
941
|
+
* `skipId` (issue #41, self-route rule on the canvas tiers): the
|
|
942
|
+
* dispatching session's own id — a live initiator occupying a member slot
|
|
943
|
+
* must not capture the dispatch, or the message only echoes home. Inbound
|
|
944
|
+
* faces pass no skip (a remote caller never equals a local member id).
|
|
945
|
+
*/
|
|
946
|
+
function canvasLiveAgent(name, skipId) {
|
|
925
947
|
if (name === undefined)
|
|
926
948
|
return undefined;
|
|
927
949
|
for (const id of canvasStore.membersOf(name)) {
|
|
950
|
+
if (id === skipId)
|
|
951
|
+
continue;
|
|
928
952
|
const agent = sessionNodes.get(id);
|
|
929
953
|
if (agent !== undefined)
|
|
930
954
|
return agent;
|
|
@@ -934,12 +958,15 @@ export function apply(ctx, config) {
|
|
|
934
958
|
/**
|
|
935
959
|
* The canvas team's first cold joined member id — the wake candidate.
|
|
936
960
|
* Cold means: joined intent remembered, no live root, not archived.
|
|
961
|
+
* `skipId` skips the initiating session for the same self-route reason as
|
|
962
|
+
* {@link canvasLiveAgent}: waking the initiator would only loop the
|
|
963
|
+
* dispatch back to its author.
|
|
937
964
|
*/
|
|
938
|
-
function canvasColdMemberId(name) {
|
|
965
|
+
function canvasColdMemberId(name, skipId) {
|
|
939
966
|
if (name === undefined)
|
|
940
967
|
return undefined;
|
|
941
968
|
const isArchived = archivedSessionFilter();
|
|
942
|
-
return canvasStore.membersOf(name).find(id => joinedSessions.has(id) && !liveRoots.has(id) && isArchived?.(id) !== true);
|
|
969
|
+
return canvasStore.membersOf(name).find(id => id !== skipId && joinedSessions.has(id) && !liveRoots.has(id) && isArchived?.(id) !== true);
|
|
943
970
|
}
|
|
944
971
|
/**
|
|
945
972
|
* Human-facing node facts for one session: the session title (fallback:
|
|
@@ -1397,715 +1424,21 @@ export function apply(ctx, config) {
|
|
|
1397
1424
|
});
|
|
1398
1425
|
};
|
|
1399
1426
|
whenWebServerSettled((webServer) => {
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
return name === undefined ? undefined : name;
|
|
1416
|
-
};
|
|
1417
|
-
// Archived sessions are closed: the GUI hides them everywhere, so
|
|
1418
|
-
// the panel must not re-list them as join surface rows — joined
|
|
1419
|
-
// or not. (The prune above already took their join intents; this
|
|
1420
|
-
// covers never-joined live roots and any intent a restart left.)
|
|
1421
|
-
const isArchived = archivedSessionFilter();
|
|
1422
|
-
const tFacts0 = Date.now();
|
|
1423
|
-
const sessions = [...liveRoots.values()]
|
|
1424
|
-
.filter(agent => isArchived?.(String(agent.id)) !== true)
|
|
1425
|
-
.map(agent => ({
|
|
1426
|
-
id: String(agent.id),
|
|
1427
|
-
label: sessionLabelOf(agent),
|
|
1428
|
-
team: sessionTeamOf(agent),
|
|
1429
|
-
...nodeMetadataOf(agent),
|
|
1430
|
-
joined: sessionNodes.has(String(agent.id)),
|
|
1431
|
-
live: true,
|
|
1432
|
-
teams: [...teamMemberships.teamsOf(String(agent.id))],
|
|
1433
|
-
// #19 D3: the session's exposure tier (linked floor /
|
|
1434
|
-
// fleet opt-up) — readable on the state face.
|
|
1435
|
-
exposure: exposureStore.tierOf(String(agent.id)),
|
|
1436
|
-
...(canvasStore.teamsOf(String(agent.id)).length > 0 ? { canvasTeams: [...canvasStore.teamsOf(String(agent.id))] } : {}),
|
|
1437
|
-
...(groupOf(String(agent.id)) !== undefined ? { group: groupOf(String(agent.id)) } : {}),
|
|
1438
|
-
}));
|
|
1439
|
-
// Cold joined sessions: a remembered intent whose Agent is not
|
|
1440
|
-
// back yet (host restarted, session not opened). The row carries
|
|
1441
|
-
// no facts — the Web client merges the session title from its
|
|
1442
|
-
// own list — and wakes through the normal session-open path,
|
|
1443
|
-
// after which the remount above joins it automatically.
|
|
1444
|
-
// Cold rows only exist when a joined intent lacks a live agent;
|
|
1445
|
-
// with all intents live (or none) the persistence layer is never
|
|
1446
|
-
// asked and the handler stays synchronous to its response.
|
|
1447
|
-
const tFacts1 = Date.now();
|
|
1448
|
-
const coldCandidates = joinedSessions.list().filter(id => !liveRoots.has(id));
|
|
1449
|
-
const persistence = ctx.get('sessionPersistence');
|
|
1450
|
-
const persisted = persistence !== undefined && coldCandidates.length > 0 ? await coldJoinedIds() : new Set();
|
|
1451
|
-
const tCold = Date.now();
|
|
1452
|
-
for (const id of coldCandidates) {
|
|
1453
|
-
// Archived intents are retained on disk (an archived window is
|
|
1454
|
-
// transient; the 3081 wake defect came from deleting them), so
|
|
1455
|
-
// the join surface must skip them here instead.
|
|
1456
|
-
if (!persisted.has(id) || isArchived?.(id) === true)
|
|
1457
|
-
continue;
|
|
1458
|
-
sessions.push({
|
|
1459
|
-
id,
|
|
1460
|
-
label: `${session}-${id8(id)}`,
|
|
1461
|
-
team: `${config.team}/${id8(id)}`,
|
|
1462
|
-
joined: true,
|
|
1463
|
-
live: false,
|
|
1464
|
-
teams: [...teamMemberships.teamsOf(id)],
|
|
1465
|
-
...(canvasStore.teamsOf(id).length > 0 ? { canvasTeams: [...canvasStore.teamsOf(id)] } : {}),
|
|
1466
|
-
...(groupOf(id) !== undefined ? { group: groupOf(id) } : {}),
|
|
1467
|
-
});
|
|
1468
|
-
}
|
|
1469
|
-
const body = JSON.stringify({
|
|
1470
|
-
nodes: true,
|
|
1471
|
-
version: PLUGIN_VERSION,
|
|
1472
|
-
sessions,
|
|
1473
|
-
groups: groupStore.list(),
|
|
1474
|
-
canvas: {
|
|
1475
|
-
teams: canvasStore.list().map(name => ({
|
|
1476
|
-
name,
|
|
1477
|
-
team: canvasTeamOf(name),
|
|
1478
|
-
members: canvasStore.membersOf(name).map(id => {
|
|
1479
|
-
// Dual shape: local members report this zone's join/live
|
|
1480
|
-
// facts; remote refs are opaque team handles — the state
|
|
1481
|
-
// face passes them through with remote:true for the
|
|
1482
|
-
// workbench's member chips and pickers.
|
|
1483
|
-
if (isRemoteMemberRef(id)) {
|
|
1484
|
-
return { id, team: id, remote: true };
|
|
1485
|
-
}
|
|
1486
|
-
return {
|
|
1487
|
-
id,
|
|
1488
|
-
team: `${config.team}/${id8(id)}`,
|
|
1489
|
-
joined: sessionNodes.has(id) || joinedSessions.has(id),
|
|
1490
|
-
live: sessionNodes.has(id),
|
|
1491
|
-
};
|
|
1492
|
-
}),
|
|
1493
|
-
})),
|
|
1494
|
-
},
|
|
1495
|
-
host: lanIp === '' ? {} : { lanIp },
|
|
1496
|
-
// gh#20 W-C (host rail): each peer row also carries its display
|
|
1497
|
-
// name and version from the warm card (absent until a fetch),
|
|
1498
|
-
// and the last-contact timestamp for the health lamp. W-A's
|
|
1499
|
-
// link/verified ride along for the badge.
|
|
1500
|
-
peers: peerStore.list().map(url => {
|
|
1501
|
-
const card = cardCache.get(url)?.card;
|
|
1502
|
-
const lastContact = peerStore.lastContact(url);
|
|
1503
|
-
const lastContactOk = peerStore.lastContactOk(url);
|
|
1504
|
-
return {
|
|
1505
|
-
url,
|
|
1506
|
-
score: peerStore.score(url),
|
|
1507
|
-
...(card?.name !== undefined && card.name !== '' ? { name: card.name } : {}),
|
|
1508
|
-
...(typeof card?.version === 'string' && card.version !== '' ? { version: card.version } : {}),
|
|
1509
|
-
...(lastContact !== undefined ? { lastContactAt: lastContact } : {}),
|
|
1510
|
-
...(lastContactOk !== undefined ? { lastContactOk } : {}),
|
|
1511
|
-
...peerLinkState(url),
|
|
1512
|
-
};
|
|
1513
|
-
}),
|
|
1514
|
-
remote: (refreshRemoteRows(), (remoteRowsCache?.rows ?? []).map(row => {
|
|
1515
|
-
const canvasTeams = canvasStore.teamsOf(row.team);
|
|
1516
|
-
return canvasTeams.length > 0 ? { ...row, canvasTeams: [...canvasTeams] } : row;
|
|
1517
|
-
})),
|
|
1518
|
-
activity: recentActivity.slice(),
|
|
1519
|
-
inFlight: [...inFlightRoutes.values()].map(route => ({
|
|
1520
|
-
team: route.team,
|
|
1521
|
-
peer: route.peer,
|
|
1522
|
-
startedAt: route.startedAt,
|
|
1523
|
-
})),
|
|
1524
|
-
// Owed receipts: pending rows only — the cross-turn waits the
|
|
1525
|
-
// in-flight ring cannot show. Dead-lettered rows ride their own
|
|
1526
|
-
// list and settled ones just a count, all additive so the panel
|
|
1527
|
-
// keeps rendering plain owed rows until it learns the tiers.
|
|
1528
|
-
tasks: taskLedger.list()
|
|
1529
|
-
.filter(task => task.status === 'pending')
|
|
1530
|
-
.map(task => ({
|
|
1531
|
-
taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, status: task.status,
|
|
1532
|
-
direction: task.direction ?? 'outbound', receiptExpected: task.receiptExpected !== false,
|
|
1533
|
-
// The dispatch brief rides every row: a task id and a team
|
|
1534
|
-
// say nothing about WHAT is owed — supervision cannot tell
|
|
1535
|
-
// a nudge from an abandon without it.
|
|
1536
|
-
...(task.brief !== undefined ? { brief: task.brief } : {}),
|
|
1537
|
-
// C-ruling bucket split: a pending row is settleable while
|
|
1538
|
-
// it is young, expected, and answerable; past the stale
|
|
1539
|
-
// hour it is conversational noise waiting on the dead TTL.
|
|
1540
|
-
settleable: task.receiptExpected !== false && Date.now() - task.startedAt < 60 * 60_000,
|
|
1541
|
-
})),
|
|
1542
|
-
// C face: the stale/conversational tail as a count, so the
|
|
1543
|
-
// panel can render only truly settleable debt.
|
|
1544
|
-
tasksStaleCount: taskLedger.list().filter(task => task.status === 'pending' && (task.receiptExpected === false || Date.now() - task.startedAt >= 60 * 60_000)).length,
|
|
1545
|
-
tasksDead: taskLedger.list()
|
|
1546
|
-
.filter(task => task.status === 'dead')
|
|
1547
|
-
.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 } : {}) })),
|
|
1548
|
-
// gh#31: per-task outcomes for rows that LEFT the ledger — the
|
|
1549
|
-
// chat drawer used to guess (settled, abandoned or cancelled).
|
|
1550
|
-
// Bounded to the archive (≤64), newest first, with the
|
|
1551
|
-
// controlled-vocabulary verdict each row settled by.
|
|
1552
|
-
// gh#31 follow-up: archive() is already newest first (settles
|
|
1553
|
-
// prepend), so the projection keeps that order — the earlier
|
|
1554
|
-
// .reverse() served it oldest first. Each row also says HOW it
|
|
1555
|
-
// ended (endedBy: abandon and cancel both record outcome
|
|
1556
|
-
// 'abandoned', and so does a peer's own 'abandoned' receipt),
|
|
1557
|
-
// whether a receipt came after the caller ended it (late), and
|
|
1558
|
-
// its direction when known.
|
|
1559
|
-
archived: taskLedger.archive().slice(0, ARCHIVE_CAP).map(row => ({
|
|
1560
|
-
taskId: row.taskId,
|
|
1561
|
-
team: row.team,
|
|
1562
|
-
peer: row.peer,
|
|
1563
|
-
startedAt: row.startedAt,
|
|
1564
|
-
resolvedAt: row.resolvedAt,
|
|
1565
|
-
outcome: row.outcome ?? 'unclassified',
|
|
1566
|
-
endedBy: archivedEndedBy(row),
|
|
1567
|
-
...(row.direction !== undefined ? { direction: row.direction } : {}),
|
|
1568
|
-
...(row.lateReceiptAt !== undefined ? { late: true } : {}),
|
|
1569
|
-
...(row.brief !== undefined ? { brief: row.brief } : {}),
|
|
1570
|
-
...(row.summary !== undefined && row.summary !== '' ? { summary: row.summary.slice(0, 160) } : {}),
|
|
1571
|
-
})),
|
|
1572
|
-
archivedCount: taskLedger.archive().length,
|
|
1573
|
-
// Ledger density face: the settled archive split by verdict, so
|
|
1574
|
-
// the workbench can say WHICH way those rows settled instead of
|
|
1575
|
-
// just how many. Additive and bounded (outcome vocabulary is a
|
|
1576
|
-
// closed set); rows without an envelope outcome count as
|
|
1577
|
-
// 'unclassified' rather than being dropped.
|
|
1578
|
-
archivedSplit: (() => {
|
|
1579
|
-
const split = {};
|
|
1580
|
-
for (const row of taskLedger.archive()) {
|
|
1581
|
-
const key = row.outcome ?? 'unclassified';
|
|
1582
|
-
split[key] = (split[key] ?? 0) + 1;
|
|
1583
|
-
}
|
|
1584
|
-
return split;
|
|
1585
|
-
})(),
|
|
1586
|
-
// 0.5.36 observability: the idempotency window aggregate —
|
|
1587
|
-
// window occupancy, outcome split, and cumulative claim-verdict
|
|
1588
|
-
// counters (the data the UNIQUE-ization decision waits on).
|
|
1589
|
-
// Read-only; no fingerprints cross here (they are /a2a/query's
|
|
1590
|
-
// only auth material).
|
|
1591
|
-
idempotency: idempotencyStore.stats(),
|
|
1592
|
-
// gh#41 fleet planning: each tracked peer's published canvas
|
|
1593
|
-
// org (full-card canvasOrg) from the warm card cache — the
|
|
1594
|
-
// board renders REMOTE team frames from this. Absent until a
|
|
1595
|
-
// linked fetch warmed the card.
|
|
1596
|
-
peerOrg: peerStore.list().map(url => {
|
|
1597
|
-
const card = cardCache.get(url)?.card;
|
|
1598
|
-
const org = card?.canvasOrg;
|
|
1599
|
-
return Array.isArray(org)
|
|
1600
|
-
? { url, org: org.filter((e) => e !== null && typeof e === 'object')
|
|
1601
|
-
.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') : [] }))
|
|
1602
|
-
.filter(e => e.team !== '') }
|
|
1603
|
-
: { url, org: [] };
|
|
1604
|
-
}),
|
|
1605
|
-
// gh#9+gh#10 M1: envelope-absent deliveries during the
|
|
1606
|
-
// OriginClaim window — the operator watches this drain to
|
|
1607
|
-
// zero before closing the window.
|
|
1608
|
-
legacyInbound: legacyInboundCount,
|
|
1609
|
-
// F-observability: the boot prewarm's lifecycle — a dead or
|
|
1610
|
-
// skipped prewarm is the root shape behind "cold rows pile up
|
|
1611
|
-
// after restart" and must be readable, not a console rumor.
|
|
1612
|
-
prewarm: { ...prewarmStatus, failed: [...prewarmStatus.failed] },
|
|
1613
|
-
// F8 observability: reconciliation cadence, wake outcomes,
|
|
1614
|
-
// and per-row failure reasons with backoff and the parked
|
|
1615
|
-
// needs-repair state — the cold row's "why am I still cold".
|
|
1616
|
-
reconcile: { ...reconcileStatus, rows: [...reconcileStatus.rows] },
|
|
1617
|
-
// S1: flat node registry — local sessions and remote card
|
|
1618
|
-
// nodes in one table, zone-qualified team handles as the
|
|
1619
|
-
// stable cross-host addresses. Local ids are the session
|
|
1620
|
-
// UUIDs; remote ids are the publishing team handles. (The
|
|
1621
|
-
// `nodes` boolean above is the capability flag the panel
|
|
1622
|
-
// already consumes; this face rides its own key.)
|
|
1623
|
-
registry: {
|
|
1624
|
-
nodes: [
|
|
1625
|
-
...sessions.map(row => ({
|
|
1626
|
-
id: String(row.id),
|
|
1627
|
-
label: String(row.label ?? ''),
|
|
1628
|
-
team: String(row.team ?? ''),
|
|
1629
|
-
zone: config.team,
|
|
1630
|
-
live: row.live === true,
|
|
1631
|
-
joined: row.joined === true,
|
|
1632
|
-
...(typeof row.workspace === 'string' && row.workspace !== '' ? { workspace: row.workspace } : {}),
|
|
1633
|
-
teams: [...teamMemberships.teamsOf(String(row.id))],
|
|
1634
|
-
})),
|
|
1635
|
-
...(remoteRowsCache?.rows ?? []).map(row => ({
|
|
1636
|
-
id: String(row.team),
|
|
1637
|
-
label: String(row.name ?? ''),
|
|
1638
|
-
team: String(row.team),
|
|
1639
|
-
zone: String(row.team).split('/')[0] ?? config.team,
|
|
1640
|
-
host: String(row.origin ?? ''),
|
|
1641
|
-
...(row.workspace !== undefined && row.workspace !== '' ? { workspace: row.workspace } : {}),
|
|
1642
|
-
...(row.legacy === true ? { legacy: true } : {}),
|
|
1643
|
-
teams: [...(row.teams ?? [])],
|
|
1644
|
-
remote: true,
|
|
1645
|
-
})),
|
|
1646
|
-
],
|
|
1647
|
-
// S2: local team rosters rebuilt from member declarations.
|
|
1648
|
-
teams: (() => {
|
|
1649
|
-
const roster = new Map();
|
|
1650
|
-
for (const entry of teamMemberships.list()) {
|
|
1651
|
-
for (const team of entry.teams) {
|
|
1652
|
-
const members = roster.get(team) ?? [];
|
|
1653
|
-
members.push(entry.session);
|
|
1654
|
-
roster.set(team, members);
|
|
1655
|
-
}
|
|
1656
|
-
}
|
|
1657
|
-
return [...roster.entries()].map(([team, members]) => ({
|
|
1658
|
-
team,
|
|
1659
|
-
members,
|
|
1660
|
-
live: members.filter(member => liveRoots.has(member)).length,
|
|
1661
|
-
}));
|
|
1662
|
-
})(),
|
|
1663
|
-
// S4 audit face: join/leave attempts with outcomes — the
|
|
1664
|
-
// queryable half that rotating log lines cannot be.
|
|
1665
|
-
teamAudit: [...teamAuditRing],
|
|
1666
|
-
},
|
|
1667
|
-
// Unjoined-node reports: the one-way signal ring (design:
|
|
1668
|
-
// docs/design/unjoined-report-ingress.md), newest first.
|
|
1669
|
-
reports: [...reportRing].reverse(),
|
|
1670
|
-
});
|
|
1671
|
-
res.writeHead(200, {
|
|
1672
|
-
'Content-Type': 'application/json',
|
|
1673
|
-
'Content-Length': Buffer.byteLength(body),
|
|
1674
|
-
// Phase timing (ms since request start): prune / live-row facts /
|
|
1675
|
-
// cold-rows enumeration. Diagnostic for the poll-latency question.
|
|
1676
|
-
'X-A2A-Timing': `prune=${tPrune - t0};facts=${tFacts1 - tFacts0};cold=${tCold - tFacts1};total=${Date.now() - t0}`,
|
|
1677
|
-
});
|
|
1678
|
-
res.end(body);
|
|
1679
|
-
})();
|
|
1680
|
-
}),
|
|
1681
|
-
}), 'a2a: session-node state route');
|
|
1682
|
-
ctx.effect(() => webServer.register({
|
|
1683
|
-
kind: 'exact',
|
|
1684
|
-
path: '/__dsh_a2a/join',
|
|
1685
|
-
handler: controlRoute((req, res) => {
|
|
1686
|
-
readJsonBody(req, res, (body) => {
|
|
1687
|
-
const agent = typeof body.id === 'string' ? liveRoots.get(body.id) : undefined;
|
|
1688
|
-
if (agent === undefined) {
|
|
1689
|
-
const payload = JSON.stringify({ error: 'no live session with that id' });
|
|
1690
|
-
res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1691
|
-
res.end(payload);
|
|
1692
|
-
return;
|
|
1693
|
-
}
|
|
1694
|
-
mountSessionNode(agent);
|
|
1695
|
-
joinedSessions.add(String(agent.id));
|
|
1696
|
-
const payload = JSON.stringify({ id: body.id, label: sessionLabelOf(agent), team: sessionTeamOf(agent) });
|
|
1697
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1698
|
-
res.end(payload);
|
|
1699
|
-
});
|
|
1700
|
-
}),
|
|
1701
|
-
}), 'a2a: session-node join route');
|
|
1702
|
-
ctx.effect(() => webServer.register({
|
|
1703
|
-
kind: 'exact',
|
|
1704
|
-
path: '/__dsh_a2a/leave',
|
|
1705
|
-
handler: controlRoute((req, res) => {
|
|
1706
|
-
readJsonBody(req, res, (body) => {
|
|
1707
|
-
const id = typeof body.id === 'string' ? body.id : '';
|
|
1708
|
-
if (id !== '')
|
|
1709
|
-
sessionNodes.delete(id);
|
|
1710
|
-
joinedSessions.remove(id);
|
|
1711
|
-
// Leaving the network leaves every canvas team too: membership
|
|
1712
|
-
// without join consent would be a routing backdoor. Team
|
|
1713
|
-
// roster declarations die the same way (S2 consistency).
|
|
1714
|
-
canvasStore.dropMember(id);
|
|
1715
|
-
teamMemberships.dropSession(id);
|
|
1716
|
-
const payload = JSON.stringify({ id });
|
|
1717
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1718
|
-
res.end(payload);
|
|
1719
|
-
});
|
|
1720
|
-
}),
|
|
1721
|
-
}), 'a2a: session-node leave route');
|
|
1722
|
-
// gh#45 CLI 户口 control face (loopback/key gated like every write):
|
|
1723
|
-
// publish (deposit a signed CLI card, ≤4h by ruling), revoke, list,
|
|
1724
|
-
// and mailbox readout — the CLI polls this while alive.
|
|
1725
|
-
ctx.effect(() => webServer.register({
|
|
1726
|
-
kind: 'exact',
|
|
1727
|
-
path: '/__dsh_a2a/cli-deposit',
|
|
1728
|
-
handler: controlRoute((req, res) => {
|
|
1729
|
-
readJsonBody(req, res, (body) => {
|
|
1730
|
-
const action = body.action;
|
|
1731
|
-
let payload;
|
|
1732
|
-
if (action === 'publish') {
|
|
1733
|
-
const card = typeof body.card === 'string' ? body.card : '';
|
|
1734
|
-
const out = cliDeposits.deposit(card);
|
|
1735
|
-
payload = JSON.stringify(out.ok
|
|
1736
|
-
? { ok: true, handle: out.handle, expiresAt: out.expiresAt, cardUrl: `${ownCardUrl}/a2a/cli/${encodeURIComponent(out.handle)}` }
|
|
1737
|
-
: { ok: false, error: out.error });
|
|
1738
|
-
}
|
|
1739
|
-
else if (action === 'revoke') {
|
|
1740
|
-
payload = JSON.stringify({ ok: cliDeposits.revoke(typeof body.handle === 'string' ? body.handle : '') });
|
|
1741
|
-
}
|
|
1742
|
-
else if (action === 'inbox') {
|
|
1743
|
-
const handle = typeof body.handle === 'string' ? body.handle : '';
|
|
1744
|
-
payload = JSON.stringify({ ok: true, mail: cliDeposits.readMail(handle) });
|
|
1745
|
-
}
|
|
1746
|
-
else {
|
|
1747
|
-
payload = JSON.stringify({ ok: true, deposits: cliDeposits.list().map(row => ({ handle: row.handle, name: row.name, depositedAt: row.depositedAt, expiresAt: row.expiresAt })) });
|
|
1748
|
-
}
|
|
1749
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1750
|
-
res.end(payload);
|
|
1751
|
-
});
|
|
1752
|
-
}),
|
|
1753
|
-
}), 'a2a: cli-deposit route');
|
|
1754
|
-
// gh#41 fleet planning relay: the GUI asks THIS host to regulate a
|
|
1755
|
-
// REMOTE peer's canvas team. The node key never leaves the server —
|
|
1756
|
-
// the relay stamps the OriginClaim envelope and forwards to the
|
|
1757
|
-
// peer's /a2a/canvas-op face; the peer's link gate authorizes.
|
|
1758
|
-
ctx.effect(() => webServer.register({
|
|
1759
|
-
kind: 'exact',
|
|
1760
|
-
path: '/__dsh_a2a/fleet-canvas-op',
|
|
1761
|
-
handler: controlRoute((req, res) => {
|
|
1762
|
-
readJsonBody(req, res, async (body) => {
|
|
1763
|
-
const { peer, op, name, id } = body;
|
|
1764
|
-
const peerUrl = typeof peer === 'string' ? peer : '';
|
|
1765
|
-
const opV = typeof op === 'string' ? op : '';
|
|
1766
|
-
const nameV = typeof name === 'string' ? name : '';
|
|
1767
|
-
const idV = typeof id === 'string' ? id : '';
|
|
1768
|
-
const send = (payload) => {
|
|
1769
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1770
|
-
res.end(payload);
|
|
1771
|
-
};
|
|
1772
|
-
if (peerUrl === '' || !/^https?:\/\//.test(peerUrl) || !['create', 'add-member', 'remove-member'].includes(opV) || (opV !== 'create' && nameV === '')) {
|
|
1773
|
-
send(JSON.stringify({ ok: false, error: 'peer (http url), op (create | add-member | remove-member), and name are required' }));
|
|
1774
|
-
return;
|
|
1775
|
-
}
|
|
1776
|
-
if (!peerStore.list().includes(peerUrl)) {
|
|
1777
|
-
send(JSON.stringify({ ok: false, error: `peer ${peerUrl} is not tracked — regulate tracked peers only` }));
|
|
1778
|
-
return;
|
|
1779
|
-
}
|
|
1780
|
-
try {
|
|
1781
|
-
const target = peerUrl.replace(/\/$/, '') + '/a2a/canvas-op';
|
|
1782
|
-
const reply = await globalThis.fetch(target, {
|
|
1783
|
-
method: 'POST',
|
|
1784
|
-
headers: { 'Content-Type': 'application/json', ...originClaimHeaders() },
|
|
1785
|
-
body: JSON.stringify({ op: opV, name: nameV, ...(opV !== 'create' ? { id: idV } : {}) }),
|
|
1786
|
-
signal: AbortSignal.timeout(15_000),
|
|
1787
|
-
});
|
|
1788
|
-
const raw = await reply.text();
|
|
1789
|
-
try {
|
|
1790
|
-
const out = JSON.parse(raw);
|
|
1791
|
-
send(JSON.stringify(out));
|
|
1792
|
-
}
|
|
1793
|
-
catch {
|
|
1794
|
-
// A non-JSON reply is a peer WITHOUT the canvas-op face (a
|
|
1795
|
-
// pre-gh41 build) or a proxy page — name the shape honestly.
|
|
1796
|
-
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` }));
|
|
1797
|
-
}
|
|
1798
|
-
}
|
|
1799
|
-
catch (error) {
|
|
1800
|
-
send(JSON.stringify({ ok: false, error: `fleet op failed: ${error instanceof Error ? error.message : String(error)}` }));
|
|
1801
|
-
}
|
|
1802
|
-
});
|
|
1803
|
-
}),
|
|
1804
|
-
}), 'a2a: fleet-canvas-op relay route');
|
|
1805
|
-
// gh#34 owner three-key panel: the remote build-out unlock knobs,
|
|
1806
|
-
// surfaced as a control face instead of "翻配置文件". GET reads the
|
|
1807
|
-
// effective values; POST writes the HOME patch layer (the
|
|
1808
|
-
// authoritative surface) AND hot-applies the two call-time keys
|
|
1809
|
-
// (teamJoinAllowlist / teamScopeInfo); announce is boot-time
|
|
1810
|
-
// publishing, so its change lands on the next host restart and the
|
|
1811
|
-
// reply says so. Same key gate as every write face.
|
|
1812
|
-
ctx.effect(() => webServer.register({
|
|
1813
|
-
kind: 'exact',
|
|
1814
|
-
path: '/__dsh_a2a/owner-settings',
|
|
1815
|
-
handler: controlRoute((req, res) => {
|
|
1816
|
-
if (req.method === 'GET') {
|
|
1817
|
-
const payload = JSON.stringify({
|
|
1818
|
-
ok: true,
|
|
1819
|
-
teamJoinAllowlist: [...ownerAllowlist],
|
|
1820
|
-
teamScopeInfo: ownerTeamScopeInfo,
|
|
1821
|
-
announce: config.announce === true,
|
|
1822
|
-
announceRestartPending: ownerAnnounceRestartPending,
|
|
1823
|
-
});
|
|
1824
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1825
|
-
res.end(payload);
|
|
1826
|
-
return;
|
|
1827
|
-
}
|
|
1828
|
-
readJsonBody(req, res, (rawBody) => {
|
|
1829
|
-
const body = rawBody;
|
|
1830
|
-
const changes = {};
|
|
1831
|
-
if (Array.isArray(body.teamJoinAllowlist)) {
|
|
1832
|
-
const list = body.teamJoinAllowlist.filter((name) => typeof name === 'string' && name !== '' && name.length <= 128);
|
|
1833
|
-
changes.teamJoinAllowlist = [...new Set(list)];
|
|
1834
|
-
}
|
|
1835
|
-
if (typeof body.teamScopeInfo === 'boolean')
|
|
1836
|
-
changes.teamScopeInfo = body.teamScopeInfo;
|
|
1837
|
-
if (typeof body.announce === 'boolean')
|
|
1838
|
-
changes.announce = body.announce;
|
|
1839
|
-
if (Object.keys(changes).length === 0) {
|
|
1840
|
-
const payload = JSON.stringify({ ok: false, error: 'no recognized keys: teamJoinAllowlist (string[]), teamScopeInfo (bool), announce (bool)' });
|
|
1841
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1842
|
-
res.end(payload);
|
|
1843
|
-
return;
|
|
1844
|
-
}
|
|
1845
|
-
try {
|
|
1846
|
-
const patchPath = join(home, 'cordis.patch.yml');
|
|
1847
|
-
const current = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '';
|
|
1848
|
-
const { yaml, restartNeeded } = applyOwnerSettings(current, changes);
|
|
1849
|
-
mkdirSync(home, { recursive: true });
|
|
1850
|
-
writeFileSync(patchPath, yaml, 'utf8');
|
|
1851
|
-
// Hot-apply the two call-time keys.
|
|
1852
|
-
if (changes.teamJoinAllowlist !== undefined)
|
|
1853
|
-
ownerAllowlist = [...changes.teamJoinAllowlist];
|
|
1854
|
-
if (changes.teamScopeInfo !== undefined)
|
|
1855
|
-
ownerTeamScopeInfo = changes.teamScopeInfo;
|
|
1856
|
-
if (changes.announce !== undefined && changes.announce !== (config.announce === true))
|
|
1857
|
-
ownerAnnounceRestartPending = true;
|
|
1858
|
-
if (changes.announce !== undefined && changes.announce === (config.announce === true))
|
|
1859
|
-
ownerAnnounceRestartPending = false;
|
|
1860
|
-
logger.info(`a2a: owner settings updated (${Object.keys(changes).join(', ')}${restartNeeded ? ' — restart pending for announce' : ''})`);
|
|
1861
|
-
const payload = JSON.stringify({ ok: true, restartNeeded, keys: Object.keys(changes) });
|
|
1862
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1863
|
-
res.end(payload);
|
|
1864
|
-
}
|
|
1865
|
-
catch (error) {
|
|
1866
|
-
const payload = JSON.stringify({ ok: false, error: `patch-layer write failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
1867
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1868
|
-
res.end(payload);
|
|
1869
|
-
}
|
|
1870
|
-
});
|
|
1871
|
-
}),
|
|
1872
|
-
}), 'a2a: owner-settings route');
|
|
1873
|
-
// Groups control: create/remove named groups and assign sessions to
|
|
1874
|
-
// them (an empty name unassigns). Same guard as join/leave — the
|
|
1875
|
-
// group taxonomy enumerates session ids, so it stays behind the key.
|
|
1876
|
-
// Deprecated as of 0.6.6 (canvas teams supersede manual grouping);
|
|
1877
|
-
// kept working for old panels.
|
|
1878
|
-
ctx.effect(() => webServer.register({
|
|
1879
|
-
kind: 'exact',
|
|
1880
|
-
path: '/__dsh_a2a/groups',
|
|
1881
|
-
handler: controlRoute((req, res) => {
|
|
1882
|
-
readJsonBody(req, res, (body) => {
|
|
1883
|
-
const action = body.action;
|
|
1884
|
-
let payload;
|
|
1885
|
-
if (action === 'create') {
|
|
1886
|
-
const name = typeof body.name === 'string' ? body.name : '';
|
|
1887
|
-
const stored = groupStore.create(name);
|
|
1888
|
-
payload = JSON.stringify(stored === undefined ? { ok: false, error: 'invalid name or group cap reached' } : { ok: true, name: stored, groups: groupStore.list() });
|
|
1889
|
-
}
|
|
1890
|
-
else if (action === 'remove') {
|
|
1891
|
-
const name = typeof body.name === 'string' ? body.name : '';
|
|
1892
|
-
payload = JSON.stringify({ ok: groupStore.remove(name), groups: groupStore.list() });
|
|
1893
|
-
}
|
|
1894
|
-
else if (action === 'assign') {
|
|
1895
|
-
const id = typeof body.id === 'string' ? body.id : '';
|
|
1896
|
-
const name = typeof body.name === 'string' ? body.name : undefined;
|
|
1897
|
-
if (id === '' || name === undefined) {
|
|
1898
|
-
payload = JSON.stringify({ ok: false, error: 'id and name are required' });
|
|
1899
|
-
}
|
|
1900
|
-
else {
|
|
1901
|
-
payload = JSON.stringify({ ok: groupStore.assign(id, name), groups: groupStore.list() });
|
|
1902
|
-
}
|
|
1903
|
-
}
|
|
1904
|
-
else {
|
|
1905
|
-
payload = JSON.stringify({ ok: false, error: 'unknown action' });
|
|
1906
|
-
}
|
|
1907
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1908
|
-
res.end(payload);
|
|
1909
|
-
});
|
|
1910
|
-
}),
|
|
1911
|
-
}), 'a2a: session-group route');
|
|
1912
|
-
// Canvas control: create/remove named multi-member teams and manage
|
|
1913
|
-
// membership. Same key guard as join/leave — membership enumerates
|
|
1914
|
-
// session ids. A member must be a joined session (live node or
|
|
1915
|
-
// remembered intent): canvas routing only reaches sessions the user
|
|
1916
|
-
// put on the network by gesture — no routing backdoor over unjoined
|
|
1917
|
-
// sessions.
|
|
1918
|
-
ctx.effect(() => webServer.register({
|
|
1919
|
-
kind: 'exact',
|
|
1920
|
-
path: '/__dsh_a2a/canvas',
|
|
1921
|
-
handler: controlRoute((req, res) => {
|
|
1922
|
-
readJsonBody(req, res, (body) => {
|
|
1923
|
-
const action = body.action;
|
|
1924
|
-
let payload;
|
|
1925
|
-
if (action === 'create') {
|
|
1926
|
-
const name = typeof body.name === 'string' ? body.name : '';
|
|
1927
|
-
const stored = canvasStore.create(name);
|
|
1928
|
-
payload = JSON.stringify(stored === undefined ? { ok: false, error: 'invalid name or team cap reached' } : { ok: true, name: stored, teams: canvasStore.list() });
|
|
1929
|
-
}
|
|
1930
|
-
else if (action === 'remove') {
|
|
1931
|
-
const name = typeof body.name === 'string' ? body.name : '';
|
|
1932
|
-
payload = JSON.stringify({ ok: canvasStore.remove(name), teams: canvasStore.list() });
|
|
1933
|
-
}
|
|
1934
|
-
else if (action === 'add-member') {
|
|
1935
|
-
const name = typeof body.name === 'string' ? body.name : '';
|
|
1936
|
-
const id = typeof body.id === 'string' ? body.id : '';
|
|
1937
|
-
// Dual member shape: a local session id must be a joined node;
|
|
1938
|
-
// a remote ref is a zone-qualified team handle whose shape this
|
|
1939
|
-
// gate checks and whose existence the UI picker (directory
|
|
1940
|
-
// rows) vouches for. Humans compose both via the workbench.
|
|
1941
|
-
const memberValid = isRemoteMemberRef(id)
|
|
1942
|
-
? validRemoteMemberRef(id)
|
|
1943
|
-
: id !== '' && (sessionNodes.has(id) || joinedSessions.has(id));
|
|
1944
|
-
if (name === '' || !memberValid) {
|
|
1945
|
-
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' });
|
|
1946
|
-
}
|
|
1947
|
-
else {
|
|
1948
|
-
canvasStore.create(name);
|
|
1949
|
-
payload = JSON.stringify({ ok: canvasStore.addMember(name, id), teams: canvasStore.list(), members: canvasStore.membersOf(name) });
|
|
1950
|
-
}
|
|
1951
|
-
}
|
|
1952
|
-
else if (action === 'remove-member') {
|
|
1953
|
-
const name = typeof body.name === 'string' ? body.name : '';
|
|
1954
|
-
const id = typeof body.id === 'string' ? body.id : '';
|
|
1955
|
-
if (name === '' || id === '') {
|
|
1956
|
-
payload = JSON.stringify({ ok: false, error: 'name and id are required' });
|
|
1957
|
-
}
|
|
1958
|
-
else {
|
|
1959
|
-
payload = JSON.stringify({ ok: canvasStore.removeMember(name, id), teams: canvasStore.list(), members: canvasStore.membersOf(name) });
|
|
1960
|
-
}
|
|
1961
|
-
}
|
|
1962
|
-
else {
|
|
1963
|
-
payload = JSON.stringify({ ok: false, error: 'unknown action' });
|
|
1964
|
-
}
|
|
1965
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1966
|
-
res.end(payload);
|
|
1967
|
-
});
|
|
1968
|
-
}),
|
|
1969
|
-
}), 'a2a: session-canvas route');
|
|
1970
|
-
// Layout face (contract v2): whole-document GET + save/reset for the
|
|
1971
|
-
// stage's spatial presentation state. Validated/clamped server-side by
|
|
1972
|
-
// LayoutStore so a buggy client cannot poison persisted geometry.
|
|
1973
|
-
ctx.effect(() => webServer.register({
|
|
1974
|
-
kind: 'exact',
|
|
1975
|
-
path: '/__dsh_a2a/canvas-layout',
|
|
1976
|
-
handler: controlRoute((req, res) => {
|
|
1977
|
-
if (req.method === 'GET') {
|
|
1978
|
-
const payload = JSON.stringify({ ok: true, layout: layoutStore.get() });
|
|
1979
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1980
|
-
res.end(payload);
|
|
1981
|
-
return;
|
|
1982
|
-
}
|
|
1983
|
-
readJsonBody(req, res, (body) => {
|
|
1984
|
-
const action = body.action;
|
|
1985
|
-
if (action === 'reset') {
|
|
1986
|
-
layoutStore.reset();
|
|
1987
|
-
const payload = JSON.stringify({ ok: true, layout: null });
|
|
1988
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1989
|
-
res.end(payload);
|
|
1990
|
-
return;
|
|
1991
|
-
}
|
|
1992
|
-
if (action === 'save') {
|
|
1993
|
-
const accepted = layoutStore.save(body.layout);
|
|
1994
|
-
const payload = JSON.stringify(accepted ? { ok: true, layout: layoutStore.get() } : { ok: false, error: 'payload is not a version-1 layout document' });
|
|
1995
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
1996
|
-
res.end(payload);
|
|
1997
|
-
return;
|
|
1998
|
-
}
|
|
1999
|
-
const payload = JSON.stringify({ ok: false, error: 'unknown action' });
|
|
2000
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2001
|
-
res.end(payload);
|
|
2002
|
-
}, MAX_LAYOUT_BODY_BYTES);
|
|
2003
|
-
}),
|
|
2004
|
-
}), 'a2a: canvas-layout route');
|
|
2005
|
-
// The dsh-a2a-munder-difflin floor stage: a same-origin full-page
|
|
2006
|
-
// surface adapted from the Munder Difflin office renderer (stage/,
|
|
2007
|
-
// NOTICE). Serves the built Vite tree with a traversal-guarded map.
|
|
2008
|
-
ctx.effect(() => webServer.register({
|
|
2009
|
-
kind: 'prefix',
|
|
2010
|
-
path: '/__dsh_a2a_canvas',
|
|
2011
|
-
handler: (req, res) => {
|
|
2012
|
-
const rootDir = fileURLToPath(new URL('../assets/stageDist', import.meta.url));
|
|
2013
|
-
// Directory-classic resolution: the bare mount 301s onto itself
|
|
2014
|
-
// with a slash, so the shell's `./assets` references resolve
|
|
2015
|
-
// inside this tree instead of 404ing against the web root.
|
|
2016
|
-
const MOUNT = '/__dsh_a2a_canvas';
|
|
2017
|
-
const resolved = resolveStageMount(req.url ?? '/', MOUNT);
|
|
2018
|
-
if (resolved.redirectTo !== undefined) {
|
|
2019
|
-
res.writeHead(301, { Location: resolved.redirectTo, 'Cache-Control': 'no-store', 'X-A2A-Stage': 'redirect' });
|
|
2020
|
-
res.end();
|
|
2021
|
-
return;
|
|
2022
|
-
}
|
|
2023
|
-
const rel = decodeURIComponent(resolved.rel).replace(/\\/g, '/');
|
|
2024
|
-
if (rel.includes('..')) {
|
|
2025
|
-
res.writeHead(403, { 'Content-Type': 'text/plain', 'X-A2A-Stage': 'traversal' });
|
|
2026
|
-
res.end();
|
|
2027
|
-
return;
|
|
2028
|
-
}
|
|
2029
|
-
try {
|
|
2030
|
-
const file = readFileSync(join(rootDir, rel));
|
|
2031
|
-
const ext = rel.slice(rel.lastIndexOf('.') + 1);
|
|
2032
|
-
const types = {
|
|
2033
|
-
html: 'text/html; charset=utf-8',
|
|
2034
|
-
js: 'text/javascript; charset=utf-8',
|
|
2035
|
-
mjs: 'text/javascript; charset=utf-8',
|
|
2036
|
-
css: 'text/css; charset=utf-8',
|
|
2037
|
-
png: 'image/png',
|
|
2038
|
-
json: 'application/json; charset=utf-8',
|
|
2039
|
-
svg: 'image/svg+xml',
|
|
2040
|
-
};
|
|
2041
|
-
res.writeHead(200, { 'Content-Type': types[ext] ?? 'application/octet-stream', 'Cache-Control': 'no-store', 'X-A2A-Stage': 'ok:' + ext });
|
|
2042
|
-
res.end(file);
|
|
2043
|
-
}
|
|
2044
|
-
catch (err) {
|
|
2045
|
-
res.writeHead(404, { 'Content-Type': 'text/plain', 'X-A2A-Stage': 'miss' });
|
|
2046
|
-
res.end('stage-miss rel=' + rel + ' rootDir=' + rootDir + ' err=' + String(err));
|
|
2047
|
-
}
|
|
2048
|
-
},
|
|
2049
|
-
}), 'a2a: canvas-stage page');
|
|
2050
|
-
// The dsh-a2a-nexus stage: Three.js infinite-canvas topology viewer.
|
|
2051
|
-
// Serves the built Vite tree from assets/nexusDist with a traversal-
|
|
2052
|
-
// guarded file map (same discipline as canvas-stage).
|
|
2053
|
-
ctx.effect(() => webServer.register({
|
|
2054
|
-
kind: 'prefix',
|
|
2055
|
-
path: '/__dsh_a2a_nexus',
|
|
2056
|
-
handler: (req, res) => {
|
|
2057
|
-
const rootDirNexus = fileURLToPath(new URL('../assets/nexusDist', import.meta.url));
|
|
2058
|
-
// Same directory-classic resolution as the canvas mount: the bare
|
|
2059
|
-
// `/__dsh_a2a_nexus` form used to serve the shell verbatim, whose
|
|
2060
|
-
// `./assets` then 404'd at the web root — a silent black viewer.
|
|
2061
|
-
const MOUNT = '/__dsh_a2a_nexus';
|
|
2062
|
-
const resolved = resolveStageMount(req.url ?? '/', MOUNT);
|
|
2063
|
-
if (resolved.redirectTo !== undefined) {
|
|
2064
|
-
res.writeHead(301, { Location: resolved.redirectTo, 'Cache-Control': 'no-store' });
|
|
2065
|
-
res.end();
|
|
2066
|
-
return;
|
|
2067
|
-
}
|
|
2068
|
-
const rel = decodeURIComponent(resolved.rel).replace(/\\/g, '/');
|
|
2069
|
-
if (rel.includes('..')) {
|
|
2070
|
-
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
2071
|
-
res.end();
|
|
2072
|
-
return;
|
|
2073
|
-
}
|
|
2074
|
-
try {
|
|
2075
|
-
const file = readFileSync(join(rootDirNexus, rel));
|
|
2076
|
-
const ext = rel.slice(rel.lastIndexOf('.') + 1);
|
|
2077
|
-
const types = {
|
|
2078
|
-
html: 'text/html; charset=utf-8',
|
|
2079
|
-
js: 'text/javascript; charset=utf-8',
|
|
2080
|
-
mjs: 'text/javascript; charset=utf-8',
|
|
2081
|
-
css: 'text/css; charset=utf-8',
|
|
2082
|
-
png: 'image/png',
|
|
2083
|
-
json: 'application/json; charset=utf-8',
|
|
2084
|
-
svg: 'image/svg+xml',
|
|
2085
|
-
};
|
|
2086
|
-
res.writeHead(200, { 'Content-Type': types[ext] ?? 'application/octet-stream', 'Cache-Control': 'no-store' });
|
|
2087
|
-
// gh#9 §10 GUI key plumbing (REVIEW M3 fix): the shell learns
|
|
2088
|
-
// the control-plane key ONLY on the loopback leniency face —
|
|
2089
|
-
// a remote bare GET of the page serves the shell WITHOUT the
|
|
2090
|
-
// key (the reviewer proved the pre-fix shape leaked the
|
|
2091
|
-
// credential to any network-reachable fetcher on a
|
|
2092
|
-
// 0.0.0.0-bound host, contradicting §10's purpose). Loopback
|
|
2093
|
-
// localhost is exactly §10.2's stated leniency posture; keyed
|
|
2094
|
-
// hosts behind tunnels use originClaimUrl-style operator
|
|
2095
|
-
// plumbing instead.
|
|
2096
|
-
if (ext === 'html' && config.apiKey !== '' && controlTrustedWithoutKey(req)) {
|
|
2097
|
-
const injected = file.toString('utf8').replace('</head>', `<script>window.__DSH_A2A_KEY__=${JSON.stringify(config.apiKey)};</script></head>`);
|
|
2098
|
-
res.end(Buffer.from(injected, 'utf8'));
|
|
2099
|
-
return;
|
|
2100
|
-
}
|
|
2101
|
-
res.end(file);
|
|
2102
|
-
}
|
|
2103
|
-
catch {
|
|
2104
|
-
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2105
|
-
res.end();
|
|
2106
|
-
}
|
|
2107
|
-
},
|
|
2108
|
-
}), 'a2a: nexus-stage page');
|
|
1427
|
+
// gh#77 B1: the admin faces live in src/faces/admin.ts.
|
|
1428
|
+
registerAdminFaces(webServer, {
|
|
1429
|
+
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,
|
|
1430
|
+
indexUrl: import.meta.url,
|
|
1431
|
+
get cardCache() { return cardCache; },
|
|
1432
|
+
get remoteRowsCache() { return remoteRowsCache; },
|
|
1433
|
+
get legacyInboundCount() { return legacyInboundCount; },
|
|
1434
|
+
get ownCardUrl() { return ownCardUrl; },
|
|
1435
|
+
get ownerAllowlist() { return ownerAllowlist; },
|
|
1436
|
+
set ownerAllowlist(v) { ownerAllowlist = v; },
|
|
1437
|
+
get ownerTeamScopeInfo() { return ownerTeamScopeInfo; },
|
|
1438
|
+
set ownerTeamScopeInfo(v) { ownerTeamScopeInfo = v; },
|
|
1439
|
+
get ownerAnnounceRestartPending() { return ownerAnnounceRestartPending; },
|
|
1440
|
+
set ownerAnnounceRestartPending(v) { ownerAnnounceRestartPending = v; },
|
|
1441
|
+
});
|
|
2109
1442
|
});
|
|
2110
1443
|
}
|
|
2111
1444
|
if (config.announce) {
|
|
@@ -2216,10 +1549,13 @@ export function apply(ctx, config) {
|
|
|
2216
1549
|
...joinedSessions.list()
|
|
2217
1550
|
.filter(id => !liveRoots.has(id) && isArchived?.(id) !== true)
|
|
2218
1551
|
.map(id => exposureRow(`${config.team}/${id8(id)}`, { name: `${session}-${id8(id)}`, description: 'cold — not loaded; routing here wakes the session' })),
|
|
2219
|
-
// gh#45: deposited CLI identities publish as directory rows —
|
|
1552
|
+
// gh#45/47: deposited CLI identities publish as directory rows —
|
|
2220
1553
|
// the fleet sees them as member cards (draggable = the operator
|
|
2221
|
-
// link). Their "zone" is the agent-name
|
|
2222
|
-
|
|
1554
|
+
// link). Their "zone" is the agent-name. gh#47: an EXPIRED
|
|
1555
|
+
// deposit stays as an OFFLINE row (tombstone) so the board keeps
|
|
1556
|
+
// the member card grayed-but-present and canvas refs never
|
|
1557
|
+
// dangle silently; re-publish (same handle) un-tombstones.
|
|
1558
|
+
...cliDeposits.directoryRows().map(row => exposureRow(row.team, { name: row.name, description: row.description })),
|
|
2223
1559
|
];
|
|
2224
1560
|
const memberships = teamMemberships.list().flatMap(entry => entry.teams.map(team => ({ node: `${config.team}/${id8(entry.session)}`, team })));
|
|
2225
1561
|
const body = JSON.stringify({
|
|
@@ -2939,6 +2275,38 @@ ${message}`;
|
|
|
2939
2275
|
for (const name of canvasStore.teamsOf(callerSessionId))
|
|
2940
2276
|
declared.add(canvasTeamOf(name));
|
|
2941
2277
|
}
|
|
2278
|
+
else {
|
|
2279
|
+
// Issue #43: a same-zone handle that resolves to NO local session is a
|
|
2280
|
+
// CROSS-HOST member (two nodes sharing one zone name — the fleet's
|
|
2281
|
+
// common `dsh` spelling). The canvas link record exists on this host
|
|
2282
|
+
// as a REMOTE MEMBER REF the operator added; the foreign-zone branch
|
|
2283
|
+
// (a2aCallerRefusal) already honors that basis — the same-zone path
|
|
2284
|
+
// must too, or a linked remote member is refused as teamless. The
|
|
2285
|
+
// trust basis is unchanged: this host's operator-composed canvas,
|
|
2286
|
+
// never the caller's self-description (V5).
|
|
2287
|
+
for (const name of canvasStore.list()) {
|
|
2288
|
+
if (canvasStore.remoteMembersOf(name).includes(caller))
|
|
2289
|
+
declared.add(canvasTeamOf(name));
|
|
2290
|
+
}
|
|
2291
|
+
// gh#71 fix ②: the warm card cache's peer canvasOrg is the same-zone
|
|
2292
|
+
// cross-host basis too — an org entry naming BOTH the caller's handle
|
|
2293
|
+
// and one of THIS host's own handles is a shared team (双向识别: the
|
|
2294
|
+
// composition gesture happened on the other host's board; both ends
|
|
2295
|
+
// are named). Read-only over the cache; a caller org that never names
|
|
2296
|
+
// an own handle still refuses (V5 preserved).
|
|
2297
|
+
const ownHandles = new Set([config.team, ...joinedSessions.list().map(id => `${config.team}/${id8(id)}`)]);
|
|
2298
|
+
for (const cached of cardCache.values()) {
|
|
2299
|
+
const org = cached.card?.canvasOrg;
|
|
2300
|
+
if (!Array.isArray(org))
|
|
2301
|
+
continue;
|
|
2302
|
+
for (const e of org) {
|
|
2303
|
+
if (typeof e.team !== 'string' || e.team === '' || !Array.isArray(e.members))
|
|
2304
|
+
continue;
|
|
2305
|
+
if (e.members.includes(caller) && e.members.some(m => typeof m === 'string' && ownHandles.has(m)))
|
|
2306
|
+
declared.add(e.team);
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2942
2310
|
const routable = new Set([config.team]);
|
|
2943
2311
|
for (const id of joinedSessions.list())
|
|
2944
2312
|
routable.add(`${config.team}/${id8(id)}`);
|
|
@@ -2978,6 +2346,20 @@ ${message}`;
|
|
|
2978
2346
|
}
|
|
2979
2347
|
for (const name of canvasStore.teamsOf(targetSession))
|
|
2980
2348
|
targetLinks.add(canvasTeamOf(name));
|
|
2349
|
+
// gh#71 fix ②: the warm cache's org basis at target level too —
|
|
2350
|
+
// an org entry naming BOTH the caller and the target handle is
|
|
2351
|
+
// the shared team (same 双向识别 rule as the host-level basis).
|
|
2352
|
+
for (const cached of cardCache.values()) {
|
|
2353
|
+
const org = cached.card?.canvasOrg;
|
|
2354
|
+
if (!Array.isArray(org))
|
|
2355
|
+
continue;
|
|
2356
|
+
for (const e of org) {
|
|
2357
|
+
if (typeof e.team !== 'string' || e.team === '' || !Array.isArray(e.members))
|
|
2358
|
+
continue;
|
|
2359
|
+
if (e.members.includes(caller) && e.members.includes(targetTeam))
|
|
2360
|
+
targetLinks.add(e.team);
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2981
2363
|
}
|
|
2982
2364
|
}
|
|
2983
2365
|
else {
|
|
@@ -3373,12 +2755,12 @@ ${message}`;
|
|
|
3373
2755
|
}
|
|
3374
2756
|
legacyInboundCount += 1;
|
|
3375
2757
|
}
|
|
2758
|
+
if (refuseIfBudgetExhausted(req, res, team, caller, taskId, taskLedger))
|
|
2759
|
+
return;
|
|
3376
2760
|
// Server-side idempotency (P3/B3): one key, one execution inside
|
|
3377
|
-
// the window. Same key + same payload
|
|
3378
|
-
//
|
|
3379
|
-
//
|
|
3380
|
-
// Checked BEFORE any steering or ledger correlation: a replay must
|
|
3381
|
-
// never re-enter the target session, no matter how it settles.
|
|
2761
|
+
// the window. Same key + same payload ⇒ replay; different payload
|
|
2762
|
+
// ⇒ hard conflict (both 409). A replay must never re-enter the
|
|
2763
|
+
// target session, no matter how it settles.
|
|
3382
2764
|
const idemFingerprint = peerPayloadFingerprint({ caller, message, noWait, team });
|
|
3383
2765
|
const idemVerdict = idempotencyStore.claim(taskId, idemFingerprint);
|
|
3384
2766
|
if (idemVerdict !== 'fresh') {
|
|
@@ -3426,7 +2808,14 @@ ${message}`;
|
|
|
3426
2808
|
// lookup settles the message is already in — answer delivered and
|
|
3427
2809
|
// let the receipt contract carry the reply.
|
|
3428
2810
|
if (noWait) {
|
|
3429
|
-
|
|
2811
|
+
// Issue #41 companion fix: the no-wait inbound branch resolved
|
|
2812
|
+
// only bare session teams — a canvas team delivery (wait:false)
|
|
2813
|
+
// fell through to the cold tier and answered a bogus
|
|
2814
|
+
// no-match although a live member existed. Same resolution as
|
|
2815
|
+
// the waiting face (routeIntoAgent), and likewise NO initiator
|
|
2816
|
+
// skip: the wire face delivers faithfully to the first live
|
|
2817
|
+
// member, whoever the caller is.
|
|
2818
|
+
const agent = resolveAgentForTeam(team) ?? canvasLiveAgent(parseCanvasTeamName(team));
|
|
3430
2819
|
const wake = agent !== undefined ? undefined : wakeColdTeam(team);
|
|
3431
2820
|
const deliver = (target) => {
|
|
3432
2821
|
const from = caller === '' ? 'an unknown node' : caller;
|
|
@@ -4130,6 +3519,18 @@ ${message}`;
|
|
|
4130
3519
|
async function fetchPeerCard(peer) {
|
|
4131
3520
|
return settlePeerCard(peer, await client.fetchCardDetail(peer));
|
|
4132
3521
|
}
|
|
3522
|
+
/**
|
|
3523
|
+
* Apply-scope shared card cache: every caller (teams listing, route
|
|
3524
|
+
* candidate walks, panel sweeps) shares one TTL window per URL, so a
|
|
3525
|
+
* poll-happy consumer cannot re-fetch a peer's card — or re-punish a dead
|
|
3526
|
+
* one — more than once per window. Cards live for days and re-sign at
|
|
3527
|
+
* TTL/4, so a 60s serve window is semantically safe. Negative results
|
|
3528
|
+
* (unreachable/invalid) cache for a shorter window so a restarting peer
|
|
3529
|
+
* reappears quickly. Scoring stays with the real network fetch only: a
|
|
3530
|
+
* cache hit never re-scores, which is what keeps the debounced peer-store
|
|
3531
|
+
* writes rare. Concurrent callers for one URL join a single in-flight
|
|
3532
|
+
* fetch (single-flight) instead of racing N requests.
|
|
3533
|
+
*/
|
|
4133
3534
|
const cardCache = new Map();
|
|
4134
3535
|
const cardCacheInFlight = new Map();
|
|
4135
3536
|
/**
|
|
@@ -4399,7 +3800,7 @@ ${message}`;
|
|
|
4399
3800
|
allowlist: {
|
|
4400
3801
|
type: 'object',
|
|
4401
3802
|
additionalProperties: true,
|
|
4402
|
-
description: 'The owner-curated teamJoinAllowlist
|
|
3803
|
+
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).',
|
|
4403
3804
|
},
|
|
4404
3805
|
teamScopeRouting: { type: 'boolean' },
|
|
4405
3806
|
note: { type: 'string', description: 'What join does and does not gate, in one sentence.' },
|
|
@@ -4768,7 +4169,9 @@ ${message}`;
|
|
|
4768
4169
|
+ 'grouping when a fleet spans machines), and the session\'s working directory when shared. Pass query to filter '
|
|
4769
4170
|
+ 'by keyword (case-insensitive substring over team name, title, excerpt, origin, or workspace) — searching '
|
|
4770
4171
|
+ 'discovers one extra referral hop within the call. Canvas teams (<team>/canvas/<name>, user-composed multi-member groups) '
|
|
4771
|
-
+ 'list as local rows; routing to one resolves the first live member or wakes the first cold one
|
|
4172
|
+
+ 'list as local rows; routing to one resolves the first live member or wakes the first cold one — the dispatching session '
|
|
4173
|
+
+ 'itself is skipped on the local tiers, falling through to the other members (issue #41: a member routing to its own group '
|
|
4174
|
+
+ 'never loops home). '
|
|
4772
4175
|
+ 'While teamScopeInfo is on (default), the listing follows YOUR collaboration circle: remote rows serve only teams joined by this host\'s nodes, and [this host] session rows show only your own row, your canvas teams, and sessions sharing a team with you — an exposed node id invites mis-routing, so the directory does not advertise strangers. The owner\'s GUI panel keeps the full plane; teamScopeInfo: false opens it for tools too. '
|
|
4773
4176
|
+ 'Call this before a2a_route to pick a target team.',
|
|
4774
4177
|
parameters: {
|
|
@@ -5135,11 +4538,12 @@ ${message}`;
|
|
|
5135
4538
|
// node team when it has joined, else the node label), so the receiver
|
|
5136
4539
|
// can answer with one a2a_route instead of an unroutable display label.
|
|
5137
4540
|
a2aJoinGateRefusal(agent === undefined ? undefined : { agent });
|
|
5138
|
-
// S3 (team-scoped routing,
|
|
5139
|
-
//
|
|
5140
|
-
// declared membership in the target team before it
|
|
5141
|
-
// and a caller with no teams at all has no network —
|
|
5142
|
-
// is, by the same ruling, an unjoined one. Host-side
|
|
4541
|
+
// S3 (team-scoped routing, phase-2 default ON per the 2026-09-01 owner
|
|
4542
|
+
// ruling; set false to restore the open outbound seam): a session
|
|
4543
|
+
// caller must have declared membership in the target team before it
|
|
4544
|
+
// may address it, and a caller with no teams at all has no network —
|
|
4545
|
+
// a teamless node is, by the same ruling, an unjoined one. Host-side
|
|
4546
|
+
// calls (no agent)
|
|
5143
4547
|
// and the initiator face stay exempt, like the join gate; discovery
|
|
5144
4548
|
// (a2a_teams) stays open so a teamless node can still see what to join.
|
|
5145
4549
|
if (config.teamScopeRouting && agent !== undefined) {
|
|
@@ -5152,13 +4556,48 @@ ${message}`;
|
|
|
5152
4556
|
if (!initiator) {
|
|
5153
4557
|
const callerTeams = teamMemberships.teamsOf(scopeCaller);
|
|
5154
4558
|
const team = String(args.team ?? '').trim();
|
|
5155
|
-
|
|
4559
|
+
// gh#71: the SHARED-CANVAS basis and the warm cache's peer-org
|
|
4560
|
+
// basis (an org entry naming BOTH the caller and the target
|
|
4561
|
+
// handle) count as declared here too — the same link the
|
|
4562
|
+
// admission core honors on the receiving side; without this the
|
|
4563
|
+
// tool face refused routes the canvas itself had authorized.
|
|
4564
|
+
// Canvas members mix two shapes: LOCAL sessions sit by session
|
|
4565
|
+
// id, cross-host members by zone/id8 handle — match either.
|
|
4566
|
+
const callerHandle = `${config.team}/${id8(scopeCaller)}`;
|
|
4567
|
+
// gh#45 末修 (66abc2cf defect): a CANVAS GROUP NAME target
|
|
4568
|
+
// (`<team>/canvas/<name>`) never matches member handles — a
|
|
4569
|
+
// whole-group dispatch from a group member was refused. The
|
|
4570
|
+
// group's own team handle (canvasTeamOf(name)) is the match
|
|
4571
|
+
// (the caller sits in the very group being addressed).
|
|
4572
|
+
const sharedCanvas = canvasStore.list().some(name => {
|
|
4573
|
+
const members = canvasStore.membersOf(name);
|
|
4574
|
+
const inGroup = members.includes(scopeCaller) || members.includes(callerHandle);
|
|
4575
|
+
return inGroup ? members.includes(team) || canvasTeamOf(name) === team : false;
|
|
4576
|
+
});
|
|
4577
|
+
let orgShared = false;
|
|
4578
|
+
for (const cached of cardCache.values()) {
|
|
4579
|
+
const org = cached.card?.canvasOrg;
|
|
4580
|
+
if (!Array.isArray(org))
|
|
4581
|
+
continue;
|
|
4582
|
+
for (const e of org) {
|
|
4583
|
+
if (typeof e.team !== 'string' || e.team === '' || !Array.isArray(e.members))
|
|
4584
|
+
continue;
|
|
4585
|
+
const inOrg = e.members.includes(scopeCaller) || e.members.includes(callerHandle);
|
|
4586
|
+
if (inOrg && (e.members.includes(team) || e.team === team)) {
|
|
4587
|
+
orgShared = true;
|
|
4588
|
+
break;
|
|
4589
|
+
}
|
|
4590
|
+
}
|
|
4591
|
+
if (orgShared)
|
|
4592
|
+
break;
|
|
4593
|
+
}
|
|
4594
|
+
if (callerTeams.length === 0 && !sharedCanvas && !orgShared) {
|
|
5156
4595
|
return {
|
|
5157
4596
|
ok: false,
|
|
5158
4597
|
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)`,
|
|
5159
4598
|
};
|
|
5160
4599
|
}
|
|
5161
|
-
if (!callerTeams.includes(team)) {
|
|
4600
|
+
if (!callerTeams.includes(team) && !sharedCanvas && !orgShared) {
|
|
5162
4601
|
return {
|
|
5163
4602
|
ok: false,
|
|
5164
4603
|
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)`,
|
|
@@ -5197,7 +4636,7 @@ ${message}`;
|
|
|
5197
4636
|
// cheapest possible and immune to nested-signal aborts), then direct
|
|
5198
4637
|
// publishers, then zone delegations, deduplicated by URL.
|
|
5199
4638
|
const failures = [];
|
|
5200
|
-
const outcome = await dispatchLocalCandidate(args.team, args.message, callerSession, args.context_id, signal, args.async === true, taskId, callbackAddress);
|
|
4639
|
+
const outcome = await dispatchLocalCandidate(args.team, args.message, callerSession, args.context_id, signal, args.async === true, taskId, callbackAddress, agent === undefined ? undefined : String(agent.id));
|
|
5201
4640
|
if (outcome !== undefined) {
|
|
5202
4641
|
if (outcome.ok) {
|
|
5203
4642
|
trackOwedTask(taskId, args.team, 'local', outcome, callbackAddress, briefOf(args.message));
|
|
@@ -5219,6 +4658,13 @@ ${message}`;
|
|
|
5219
4658
|
if (remoteTargets.length > 0) {
|
|
5220
4659
|
failures.push(`local: canvas team "${args.team}" has no live or wakeable local member`);
|
|
5221
4660
|
}
|
|
4661
|
+
else if (canvasName !== undefined && canvasStore.hasTeam(canvasName)) {
|
|
4662
|
+
// Issue #41: no remote members and no usable local tier — the only
|
|
4663
|
+
// remaining candidate shape is the initiator itself, and routing
|
|
4664
|
+
// there would loop. Name it instead of letting the directory walk
|
|
4665
|
+
// report an unrelated miss.
|
|
4666
|
+
failures.push(`local: canvas team "${args.team}" has no live or wakeable member besides the initiating session — routing to a canvas team whose only member is the caller itself would loop; target another member's team handle directly`);
|
|
4667
|
+
}
|
|
5222
4668
|
const candidatePool = [];
|
|
5223
4669
|
for (const target of targets) {
|
|
5224
4670
|
for (const candidate of await directoryPeerCandidates(fetch, target, failures)) {
|
|
@@ -5275,7 +4721,7 @@ ${message}`;
|
|
|
5275
4721
|
* and the caller gets the delivered shape with the receipt contract.
|
|
5276
4722
|
* @returns the canonical result, or undefined when the team is not local.
|
|
5277
4723
|
*/
|
|
5278
|
-
async function dispatchLocalCandidate(team, message, callerSession, contextId, signal, asyncMode = false, taskIdFromCaller, callbackAddress) {
|
|
4724
|
+
async function dispatchLocalCandidate(team, message, callerSession, contextId, signal, asyncMode = false, taskIdFromCaller, callbackAddress, originAgentId) {
|
|
5279
4725
|
const webServer = ctx.get('webServer');
|
|
5280
4726
|
// W1 (boundary hardening): the local in-process candidate is an
|
|
5281
4727
|
// admission seam like any other — a JOINED session (zone/id8 caller
|
|
@@ -5290,10 +4736,20 @@ ${message}`;
|
|
|
5290
4736
|
return { ok: false, error: joinedRefusal, code: -32000 };
|
|
5291
4737
|
}
|
|
5292
4738
|
const canvasName = parseCanvasTeamName(team);
|
|
4739
|
+
// Issue #41 shape note: the caller label is the WIRE form (<zone>/<id8>
|
|
4740
|
+
// or a session label) and quick-dispatch BORROWS a live session's team
|
|
4741
|
+
// as its host face — so the label alone cannot tell an initiating agent
|
|
4742
|
+
// from a borrowed one. The exemption keys on the ORIGINATING agent's id
|
|
4743
|
+
// (same shape as a member slot) passed down by routeTrunk; the host face
|
|
4744
|
+
// passes none and never exempts.
|
|
4745
|
+
const callerNodeId = originAgentId;
|
|
5293
4746
|
let teamIsLocal = team === config.team
|
|
5294
4747
|
|| resolveAgentForTeam(team) !== undefined
|
|
5295
4748
|
|| joinedSessions.list().some(id => `${config.team}/${id8(id)}` === team)
|
|
5296
|
-
|
|
4749
|
+
// Issue #41 (self-route rule on canvas tiers): the initiator's own
|
|
4750
|
+
// member slot never makes the team local — an exempt local tier must
|
|
4751
|
+
// fall through to the remote members instead of echoing home.
|
|
4752
|
+
|| (canvasName !== undefined && (canvasLiveAgent(canvasName, callerNodeId) !== undefined || canvasColdMemberId(canvasName, callerNodeId) !== undefined));
|
|
5297
4753
|
// gh#45 CLI mailbox: a delivery targeting a DEPOSITED CLI handle is
|
|
5298
4754
|
// local by definition — it lands in the deposit's mailbox (the CLI
|
|
5299
4755
|
// polls while alive; receipts and messages never wake anything).
|
|
@@ -5322,8 +4778,10 @@ ${message}`;
|
|
|
5322
4778
|
// materializes first (the wake settles, or fails honestly), then the
|
|
5323
4779
|
// steer fires, then delivered answers. The receipt header carries the
|
|
5324
4780
|
// task id so the target can echo it back verbatim.
|
|
5325
|
-
|
|
5326
|
-
|
|
4781
|
+
// Issue #41: the canvas live tier skips the initiating session, so a
|
|
4782
|
+
// dispatch from a member never resolves back to its author.
|
|
4783
|
+
const agent = resolveAgentForTeam(team) ?? canvasLiveAgent(canvasName, callerNodeId);
|
|
4784
|
+
const wake = agent !== undefined ? undefined : wakeColdTeam(team, callerNodeId);
|
|
5327
4785
|
let woken = agent !== undefined
|
|
5328
4786
|
? Promise.resolve(agent)
|
|
5329
4787
|
: wake?.kind === 'flight' ? wake.flight : undefined;
|
|
@@ -5415,10 +4873,13 @@ ${message}`;
|
|
|
5415
4873
|
// registry claim must never shadow a live local session team. The
|
|
5416
4874
|
// caller's abort cancels the round through its own signal; the 180s
|
|
5417
4875
|
// deadline restores the bound the steer machinery observes.
|
|
4876
|
+
// Issue #41: same initiator exemption on the sync path — the canvas
|
|
4877
|
+
// live/cold tiers skip the dispatching session before steerable is
|
|
4878
|
+
// decided, so a self-member falls through to remote members.
|
|
5418
4879
|
const steerable = resolveAgentForTeam(team) !== undefined
|
|
5419
|
-
|| canvasLiveAgent(canvasName) !== undefined
|
|
4880
|
+
|| canvasLiveAgent(canvasName, callerNodeId) !== undefined
|
|
5420
4881
|
|| joinedSessions.list().some(id => `${config.team}/${id8(id)}` === team)
|
|
5421
|
-
|| wakeColdTeam(team).kind === 'flight';
|
|
4882
|
+
|| wakeColdTeam(team, callerNodeId).kind === 'flight';
|
|
5422
4883
|
if (!steerable && team !== config.team) {
|
|
5423
4884
|
const prepared = await nativeTeamsPrepare(team);
|
|
5424
4885
|
if (prepared.ok) {
|
|
@@ -6154,8 +5615,13 @@ ${message}`;
|
|
|
6154
5615
|
// and learns its referrals, exactly like a discovery sweep would,
|
|
6155
5616
|
// while the outcome's stage names the failure for the report.
|
|
6156
5617
|
const card = settlePeerCard(url, outcome);
|
|
6157
|
-
if (card !== undefined)
|
|
5618
|
+
if (card !== undefined) {
|
|
5619
|
+
// gh#71 fix ①: a successful full-face fetch persists into the warm
|
|
5620
|
+
// cache — the probe is often the FIRST contact (no directory sweep
|
|
5621
|
+
// has run), and the state face's peerOrg reads exactly this cache.
|
|
5622
|
+
cardCache.set(url, { at: Date.now(), card });
|
|
6158
5623
|
return { url, reachable: true, ms, team: card.team };
|
|
5624
|
+
}
|
|
6159
5625
|
if (outcome.ok)
|
|
6160
5626
|
return { url, reachable: false, ms, error: 'dropped: the card lists this node back as its own referral' };
|
|
6161
5627
|
return outcome.stage === 'unreachable'
|