@nelsonlongxiang/dsh-open-a2a-net 0.6.29 → 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 +2 -0
- package/README_zh.md +2 -0
- 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 +624 -624
- 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 +12 -1
- 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 +69 -663
- package/lib/skill/SKILL.md +1 -0
- package/lib/skill/references/collaboration.md +355 -0
- package/lib/task-ledger.js +34 -4
- package/lib/types/canvas-store.d.ts +10 -1
- package/lib/types/faces/admin.d.ts +2 -1
- 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/runtime.d.ts +1 -0
- package/lib/types/task-ledger.d.ts +9 -1
- package/nexus-stage/src/main.ts +5 -1
- package/nexus-stage/src/planning-edges.ts +26 -0
- package/nexus-stage/src/planning-mirror.ts +31 -0
- package/nexus-stage/src/planning-overlap.ts +36 -11
- 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 +58 -34
- package/nexus-stage/src/planning.css +2 -0
- package/nexus-stage/src/world.ts +8 -20
- package/package.json +1 -1
- package/assets/nexusDist/assets/index-BqepxJ7E.css +0 -1
- package/assets/nexusDist/assets/index-CTWH6QRh.js +0 -9
package/lib/index.js
CHANGED
|
@@ -28,7 +28,7 @@ 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
34
|
import { JoinedSessions } from "./joined-store.js";
|
|
@@ -41,7 +41,7 @@ import { TaskLedger, TASK_STALE_TTL_MS, SUMMARY_CAP, briefOf } from "./task-ledg
|
|
|
41
41
|
import { formatReceipt, parseReceipt } from "./receipt.js";
|
|
42
42
|
import { runReceiptLadder } from "./receipt-ladder.js";
|
|
43
43
|
import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
|
|
44
|
-
import {
|
|
44
|
+
import { createFloodGuard } from "./flood-guard.js";
|
|
45
45
|
import { resolveZone } from "./zone.js";
|
|
46
46
|
import { registerA2aNetworkSkill } from "./skill-registration.js";
|
|
47
47
|
import s from '@deepseek-ai/schemastery';
|
|
@@ -49,8 +49,9 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
|
49
49
|
import { MessageId } from '@deepseek-ai/dsh-llm';
|
|
50
50
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
51
51
|
import { A2aClient } from "./a2a-client.js";
|
|
52
|
-
import { WIRE_ERROR_PAYLOAD_TOO_LARGE
|
|
52
|
+
import { WIRE_ERROR_PAYLOAD_TOO_LARGE } from "./transport-caps.js";
|
|
53
53
|
import { registerAdminFaces } from "./faces/admin.js";
|
|
54
|
+
import { registerWireFaces } from "./faces/wire.js";
|
|
54
55
|
import { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
|
|
55
56
|
export { A2aClient } from "./a2a-client.js";
|
|
56
57
|
export { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
|
|
@@ -706,6 +707,7 @@ export function apply(ctx, config) {
|
|
|
706
707
|
// executes at most once inside the 24h window — same-key replays answer
|
|
707
708
|
// refused-but-idempotent, same-key different-payload answers conflict.
|
|
708
709
|
const idempotencyStore = new IdempotencyStore(join(home, 'a2a', 'idempotency.json'));
|
|
710
|
+
const refuseIfFlooded = createFloodGuard(idempotencyStore, recordActivity);
|
|
709
711
|
// Unmasked-identity suppression: gossip mirrors peer lists back and forth,
|
|
710
712
|
// so a URL whose fetch just proved it serves this node — or an already
|
|
711
713
|
// tracked node (an alias spelling) — keeps coming home as an inbound
|
|
@@ -1426,7 +1428,7 @@ export function apply(ctx, config) {
|
|
|
1426
1428
|
whenWebServerSettled((webServer) => {
|
|
1427
1429
|
// gh#77 B1: the admin faces live in src/faces/admin.ts.
|
|
1428
1430
|
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,
|
|
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,
|
|
1430
1432
|
indexUrl: import.meta.url,
|
|
1431
1433
|
get cardCache() { return cardCache; },
|
|
1432
1434
|
get remoteRowsCache() { return remoteRowsCache; },
|
|
@@ -2527,664 +2529,48 @@ ${message}`;
|
|
|
2527
2529
|
return { ok: true, id, messages: entries.map(entry => ({ role: entry.role, text: entry.text, ...(entry.time !== undefined ? { time: entry.time } : {}) })) };
|
|
2528
2530
|
}
|
|
2529
2531
|
whenWebServerSettled((webServer) => {
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
res.end(payload);
|
|
2573
|
-
};
|
|
2574
|
-
if (req.method !== 'POST') {
|
|
2575
|
-
send(200, JSON.stringify({ ok: false, error: 'POST required' }));
|
|
2576
|
-
return;
|
|
2577
|
-
}
|
|
2578
|
-
const chunks = [];
|
|
2579
|
-
let size = 0;
|
|
2580
|
-
req.on('data', (chunk) => { size += chunk.length; if (size > 16 * 1024) {
|
|
2581
|
-
chunks.length = 0;
|
|
2582
|
-
return;
|
|
2583
|
-
} chunks.push(chunk); });
|
|
2584
|
-
req.on('end', () => {
|
|
2585
|
-
void (async () => {
|
|
2586
|
-
try {
|
|
2587
|
-
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
2588
|
-
const claim = await verifyOriginClaim(req);
|
|
2589
|
-
if (!claim.ok) {
|
|
2590
|
-
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'] ?? '')}`);
|
|
2591
|
-
send(200, JSON.stringify({ ok: false, error: `untrusted-origin: OriginClaim verification failed (${claim.reason}) — canvas regulation requires a signed envelope`, code: -32000 }));
|
|
2592
|
-
return;
|
|
2593
|
-
}
|
|
2594
|
-
if (!callerLinkedToThisHost(claim.caller, claim.callerUrl, claim.card)) {
|
|
2595
|
-
recordActivity('in', 'fleet-canvas', claim.caller, false);
|
|
2596
|
-
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)` }));
|
|
2597
|
-
return;
|
|
2598
|
-
}
|
|
2599
|
-
const op = body.op;
|
|
2600
|
-
const name = typeof body.name === 'string' ? body.name : '';
|
|
2601
|
-
const id = typeof body.id === 'string' ? body.id : '';
|
|
2602
|
-
let payload;
|
|
2603
|
-
if (op === 'create') {
|
|
2604
|
-
const stored = canvasStore.create(name);
|
|
2605
|
-
payload = stored === undefined ? { ok: false, error: 'invalid name or team cap reached' } : { ok: true, members: canvasStore.membersOf(stored) };
|
|
2606
|
-
}
|
|
2607
|
-
else if (op === 'add-member') {
|
|
2608
|
-
const memberValid = isRemoteMemberRef(id) ? validRemoteMemberRef(id) : id !== '' && (sessionNodes.has(id) || joinedSessions.has(id));
|
|
2609
|
-
payload = name === '' || !memberValid
|
|
2610
|
-
? { ok: false, error: 'name and a member (joined session id, or a zone-qualified remote team handle) are required' }
|
|
2611
|
-
: (canvasStore.create(name), { ok: canvasStore.addMember(name, id), members: canvasStore.membersOf(name) });
|
|
2612
|
-
}
|
|
2613
|
-
else if (op === 'remove-member') {
|
|
2614
|
-
payload = { ok: name !== '' && canvasStore.removeMember(name, id), members: name !== '' ? canvasStore.membersOf(name) : undefined };
|
|
2615
|
-
}
|
|
2616
|
-
else {
|
|
2617
|
-
payload = { ok: false, error: 'op must be create | add-member | remove-member' };
|
|
2618
|
-
}
|
|
2619
|
-
recordActivity('in', 'fleet-canvas', `${claim.caller}:${String(op)}`, payload.ok);
|
|
2620
|
-
logger.info(`a2a: fleet canvas op from ${claim.caller} (${String(op)} ${name}${id !== '' ? ` ${id}` : ''}) -> ${payload.ok ? 'applied' : `refused: ${payload.error ?? '?'}`}`);
|
|
2621
|
-
send(200, JSON.stringify(payload));
|
|
2622
|
-
}
|
|
2623
|
-
catch (error) {
|
|
2624
|
-
send(200, JSON.stringify({ ok: false, error: `canvas-op failed: ${error instanceof Error ? error.message : String(error)}` }));
|
|
2625
|
-
}
|
|
2626
|
-
})();
|
|
2627
|
-
});
|
|
2628
|
-
},
|
|
2629
|
-
}), 'a2a: fleet canvas-op peer route');
|
|
2630
|
-
ctx.effect(() => webServer.register({
|
|
2631
|
-
kind: 'exact',
|
|
2632
|
-
path: '/a2a/direct',
|
|
2633
|
-
handler: async (req, res) => {
|
|
2634
|
-
const chunks = [];
|
|
2635
|
-
let size = 0;
|
|
2636
|
-
// B5 enforcement: 512 KiB hard cap. An oversized body is rejected,
|
|
2637
|
-
// never truncated and never connection-killed mid-read — buffering
|
|
2638
|
-
// stops at the crossing chunk, the stream drains to `end`, and the
|
|
2639
|
-
// peer receives one structured 413 with a wire error code (the old
|
|
2640
|
-
// behavior tore the socket down with no diagnosis at all).
|
|
2641
|
-
let overflowed = false;
|
|
2642
|
-
const declared = Number.parseInt(String(req.headers['content-length'] ?? ''), 10);
|
|
2643
|
-
if (Number.isFinite(declared) && !withinRouteBodyCap(declared))
|
|
2644
|
-
overflowed = true;
|
|
2645
|
-
req.on('data', (chunk) => {
|
|
2646
|
-
if (overflowed)
|
|
2647
|
-
return;
|
|
2648
|
-
const next = size + chunk.length;
|
|
2649
|
-
if (!withinRouteBodyCap(next)) {
|
|
2650
|
-
overflowed = true;
|
|
2651
|
-
chunks.length = 0;
|
|
2652
|
-
return;
|
|
2653
|
-
}
|
|
2654
|
-
size = next;
|
|
2655
|
-
chunks.push(chunk);
|
|
2656
|
-
});
|
|
2657
|
-
/* v8 ignore next 6 -- client aborts surface as 'close' via the shared
|
|
2658
|
-
webServer's per-request guard; a raw req 'error' needs a malformed
|
|
2659
|
-
HTTP stream the webServer rejects before this handler exists */
|
|
2660
|
-
req.on('error', () => {
|
|
2661
|
-
if (!res.headersSent) {
|
|
2662
|
-
res.writeHead(400);
|
|
2663
|
-
res.end();
|
|
2664
|
-
}
|
|
2665
|
-
});
|
|
2666
|
-
req.on('end', async () => {
|
|
2667
|
-
if (overflowed) {
|
|
2668
|
-
if (!res.writableEnded && !res.headersSent) {
|
|
2669
|
-
const payload = JSON.stringify({ error: 'payload too large', code: WIRE_ERROR_PAYLOAD_TOO_LARGE });
|
|
2670
|
-
res.writeHead(413, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2671
|
-
res.end(payload);
|
|
2672
|
-
}
|
|
2673
|
-
return;
|
|
2674
|
-
}
|
|
2675
|
-
let body;
|
|
2676
|
-
try {
|
|
2677
|
-
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
2678
|
-
}
|
|
2679
|
-
catch {
|
|
2680
|
-
const payload = JSON.stringify({ error: 'malformed body', code: -32000 });
|
|
2681
|
-
res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2682
|
-
res.end(payload);
|
|
2683
|
-
return;
|
|
2684
|
-
}
|
|
2685
|
-
// V2 (gh#7, S1): the delivery face honors the apiKey gate exactly
|
|
2686
|
-
// like the control face — a configured key without this header
|
|
2687
|
-
// used to let anyone reachable steer sessions while the control
|
|
2688
|
-
// routes demanded the key, making it decorative for delivery.
|
|
2689
|
-
// Empty-key deployments keep the documented open posture.
|
|
2690
|
-
if (config.apiKey !== '') {
|
|
2691
|
-
const presented = String(req.headers['x-api-key'] ?? '');
|
|
2692
|
-
if (!secretEqual(presented, config.apiKey)) {
|
|
2693
|
-
const payload = JSON.stringify({ error: 'unauthorized: X-API-Key required on the delivery face (apiKey is configured)', code: -32000 });
|
|
2694
|
-
res.writeHead(401, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2695
|
-
res.end(payload);
|
|
2696
|
-
return;
|
|
2697
|
-
}
|
|
2698
|
-
}
|
|
2699
|
-
const team = typeof body.team === 'string' ? body.team : '';
|
|
2700
|
-
const message = typeof body.message === 'string' ? body.message : '';
|
|
2701
|
-
const caller = typeof body.caller_session === 'string' ? body.caller_session : '';
|
|
2702
|
-
// P2 receipt-callback: where THIS node's receipt should route on
|
|
2703
|
-
// the caller's side — the caller knows its routable address best
|
|
2704
|
-
// (a joined session's node team, e.g. via the transport face's
|
|
2705
|
-
// callbackTarget mapping). Bounded; absent → the caller label
|
|
2706
|
-
// plays its usual role.
|
|
2707
|
-
const callback = typeof body.callback === 'string' && body.callback !== '' && body.callback.length <= 128 ? body.callback : '';
|
|
2708
|
-
const noWait = body.wait === false;
|
|
2709
|
-
const contextId = typeof body.context_id === 'string' && body.context_id !== '' ? body.context_id : `ctx-${Math.random().toString(16).slice(2, 10)}`;
|
|
2710
|
-
// The caller-born task id (idempotency key): echo it when present
|
|
2711
|
-
// so the receipt header correlates with the caller's own result;
|
|
2712
|
-
// pre-0.5.3 callers carry none and this node mints one.
|
|
2713
|
-
const taskId = typeof body.task_id === 'string' && body.task_id !== '' ? body.task_id : `direct-${Math.random().toString(16).slice(2, 10)}`;
|
|
2714
|
-
if (team === '' || message === '') {
|
|
2715
|
-
const payload = JSON.stringify({ error: 'team and message are required', code: -32000 });
|
|
2716
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2717
|
-
res.end(payload);
|
|
2718
|
-
return;
|
|
2719
|
-
}
|
|
2720
|
-
// Phase-3 admission (owner ruling 2026-09-01, decision-seat
|
|
2721
|
-
// filed): an inbound direct delivery must show a SHARED team.
|
|
2722
|
-
// Local-zone callers are judged synchronously and exactly — the
|
|
2723
|
-
// declaration lives in this host's own roster store, so the
|
|
2724
|
-
// Anonymous-delivery refusal (S3 phase-3b, test-seat finding
|
|
2725
|
-
// direct-001649ab): a delivery with NO caller_session field
|
|
2726
|
-
// skips the admission gate entirely (nothing to judge) — that
|
|
2727
|
-
// anonymous hole let any local process or reachable peer inject
|
|
2728
|
-
// into a live session. The protocol contract is caller_session;
|
|
2729
|
-
// its absence is now refused instead of silently waved through.
|
|
2730
|
-
// (0.6.15: the rule now lives in a2aCallerRefusal so the
|
|
2731
|
-
// read-only /a2a/log face admits callers by exactly the same
|
|
2732
|
-
// standard as message delivery.)
|
|
2733
|
-
const callerRefusal = a2aCallerRefusal(caller, team);
|
|
2734
|
-
if (callerRefusal !== undefined) {
|
|
2735
|
-
recordActivity('in', team, caller === '' ? 'anonymous' : caller, false);
|
|
2736
|
-
const payload = JSON.stringify({ error: callerRefusal, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
|
|
2737
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2738
|
-
res.end(payload);
|
|
2739
|
-
return;
|
|
2740
|
-
}
|
|
2741
|
-
// gh#9+gh#10 OriginClaim on the delivery face (M1): an envelope
|
|
2742
|
-
// that is present but unverifiable rejects untrusted-origin; an
|
|
2743
|
-
// ABSENT envelope during the window walks on with a counted
|
|
2744
|
-
// legacyInbound warn (the operator watches the tail drain), and
|
|
2745
|
-
// after the window closes it rejects like a failed signature —
|
|
2746
|
-
// N3 proves a NAME's linkability, never key possession.
|
|
2747
|
-
const claim = await verifyOriginClaim(req);
|
|
2748
|
-
if (!claim.ok) {
|
|
2749
|
-
if (claim.reason !== 'absent' || config.originClaimWindow === false) {
|
|
2750
|
-
recordActivity('in', team, caller, false);
|
|
2751
|
-
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' });
|
|
2752
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2753
|
-
res.end(payload);
|
|
2754
|
-
return;
|
|
2755
|
-
}
|
|
2756
|
-
legacyInboundCount += 1;
|
|
2757
|
-
}
|
|
2758
|
-
if (refuseIfBudgetExhausted(req, res, team, caller, taskId, taskLedger))
|
|
2759
|
-
return;
|
|
2760
|
-
// Server-side idempotency (P3/B3): one key, one execution inside
|
|
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.
|
|
2764
|
-
const idemFingerprint = peerPayloadFingerprint({ caller, message, noWait, team });
|
|
2765
|
-
const idemVerdict = idempotencyStore.claim(taskId, idemFingerprint);
|
|
2766
|
-
if (idemVerdict !== 'fresh') {
|
|
2767
|
-
const replay = idemVerdict === 'replay';
|
|
2768
|
-
// 0.5.36 observability: conflict = caller bug (graph-loop prompt
|
|
2769
|
-
// minting / key management) — warn loudly so it is never misread
|
|
2770
|
-
// as the normal-depth-defense zero-traffic shape. Replays stay
|
|
2771
|
-
// silent by design.
|
|
2772
|
-
if (!replay)
|
|
2773
|
-
logger.warn(`a2a: idempotency CONFLICT on task ${taskId.slice(0, 80)} — same key reused with a different payload (caller bug; never auto-retry)`);
|
|
2774
|
-
const payload = JSON.stringify({
|
|
2775
|
-
error: replay ? 'duplicate task id within the idempotency window' : 'task id reused with a different payload',
|
|
2776
|
-
code: replay ? WIRE_ERROR_REPLAY_REJECTED : WIRE_ERROR_IDEMPOTENCY_CONFLICT,
|
|
2777
|
-
task_id: taskId,
|
|
2778
|
-
replay,
|
|
2779
|
-
});
|
|
2780
|
-
res.writeHead(409, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2781
|
-
res.end(payload);
|
|
2782
|
-
return;
|
|
2783
|
-
}
|
|
2784
|
-
// The caller-side receipt correlation: a message shaped as a
|
|
2785
|
-
// receipt resolves the task it echoes, whichever wait semantics
|
|
2786
|
-
// carried it here. Correlation is bookkeeping only — the message
|
|
2787
|
-
// steers on exactly as before.
|
|
2788
|
-
settleAndAnnounce(message);
|
|
2789
|
-
// gh#45 CLI mailbox (wire face): a delivery targeting a DEPOSITED
|
|
2790
|
-
// CLI handle never resolves a session and never wakes — it lands
|
|
2791
|
-
// in the deposit's mailbox and answers delivered. Placement here
|
|
2792
|
-
// keeps the idempotency claim (above) exactly as for sessions.
|
|
2793
|
-
const cliRow = cliDeposits.get(team);
|
|
2794
|
-
if (cliRow !== undefined) {
|
|
2795
|
-
cliDeposits.mail(team, caller, message);
|
|
2796
|
-
recordActivity('in', team, caller, true);
|
|
2797
|
-
logger.info(`a2a: cli mailbox delivery for ${team} (from ${caller})`);
|
|
2798
|
-
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` });
|
|
2799
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2800
|
-
res.end(payload);
|
|
2801
|
-
return;
|
|
2802
|
-
}
|
|
2803
|
-
// Inbound routing is foreground demand: boot prewarm yields for a
|
|
2804
|
-
// quiet window around it.
|
|
2805
|
-
lastWakeDemandAt = Date.now();
|
|
2806
|
-
// wait:false delivers without the final-reply hold: steer resolves
|
|
2807
|
-
// synchronously inside routeIntoAgentFor, so by the time the agent
|
|
2808
|
-
// lookup settles the message is already in — answer delivered and
|
|
2809
|
-
// let the receipt contract carry the reply.
|
|
2810
|
-
if (noWait) {
|
|
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));
|
|
2819
|
-
const wake = agent !== undefined ? undefined : wakeColdTeam(team);
|
|
2820
|
-
const deliver = (target) => {
|
|
2821
|
-
const from = caller === '' ? 'an unknown node' : caller;
|
|
2822
|
-
// The receipt target: the caller's callback address when it
|
|
2823
|
-
// supplied one (a session node team — wake-on-route covers a
|
|
2824
|
-
// cold one), else the caller label as before.
|
|
2825
|
-
const receiptTarget = callback !== '' ? callback : from;
|
|
2826
|
-
try {
|
|
2827
|
-
// The receipt header carries the task id: the target echoes
|
|
2828
|
-
// it verbatim in "[A2A receipt] task <id> ...", closing the
|
|
2829
|
-
// correlation loop with the caller's own route result.
|
|
2830
|
-
// F1' (consumed-probe prior-running): capture the pre-steer
|
|
2831
|
-
// status first — a target that was already running (its own
|
|
2832
|
-
// turn / a brand-new session's activation window) reads
|
|
2833
|
-
// `running` after the steer whether or not our wake was
|
|
2834
|
-
// latched, so the post-steer read alone would answer a false
|
|
2835
|
-
// consumed:true and strand the message.
|
|
2836
|
-
const runningBeforeSteer = probeConsumption(target, taskId);
|
|
2837
|
-
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);
|
|
2838
|
-
recordActivity('in', team, caller, true);
|
|
2839
|
-
armReceiptAutosend(target, taskId, receiptTarget, message);
|
|
2840
|
-
// v0.5.23 (async-stall): "delivered" only proves the steer call
|
|
2841
|
-
// returned — not that a turn started. An idle-phase steer wakes
|
|
2842
|
-
// the driver, but a maintenance/abort window latches the wake for
|
|
2843
|
-
// replay that may never fire (defect t-mt6nd0sq-hxuhj6: seven
|
|
2844
|
-
// delivered:true routes with zero log growth). Probe the driver
|
|
2845
|
-
// shortly after: running means the message is being consumed;
|
|
2846
|
-
// still-idle means the wake was latched or dropped — surface that
|
|
2847
|
-
// honestly and arm a delayed nudge retry below.
|
|
2848
|
-
// F1': a prior-running target never trusts the single
|
|
2849
|
-
// post-steer read — answer consumed:false conservatively and
|
|
2850
|
-
// arm the nudge (a false negative costs one harmless nudge; a
|
|
2851
|
-
// false positive strands the delivered message).
|
|
2852
|
-
// F2' (receipt-settlement): the delivery is born into the
|
|
2853
|
-
// ledger — an inbound row owes a receipt only this node can
|
|
2854
|
-
// produce, and before this row existed the nudge's isPending
|
|
2855
|
-
// gate no-op'd for exactly the deliveries that stall (08-30:
|
|
2856
|
-
// zero ledger records for latched activations). Arming is
|
|
2857
|
-
// unconditional now; settlement evidence disarms in the
|
|
2858
|
-
// callback.
|
|
2859
|
-
const consumedProbe = runningBeforeSteer ? false : probeConsumption(target, taskId);
|
|
2860
|
-
// F5 (settlement integrity): the debt gate — a process-label
|
|
2861
|
-
// caller cannot receive receipts, so this delivery is born
|
|
2862
|
-
// fire-and-forget (receiptExpected:false, answered as
|
|
2863
|
-
// receipt:"none") instead of manufacturing a debt nobody can
|
|
2864
|
-
// settle. The row itself stays: stall visibility and the
|
|
2865
|
-
// nudge still apply to the TARGET side.
|
|
2866
|
-
const receiptExpectation = receiptExpectationOf(receiptTarget);
|
|
2867
|
-
taskLedger.trackInbound(taskId, team, receiptTarget, contextId === '' ? undefined : contextId, receiptExpectation !== 'process-label', briefOf(message));
|
|
2868
|
-
armAsyncNudge(target, team, taskId, config.asyncNudgeDelayMs);
|
|
2869
|
-
const payload = JSON.stringify({
|
|
2870
|
-
routed: true,
|
|
2871
|
-
delivered: true,
|
|
2872
|
-
team,
|
|
2873
|
-
session,
|
|
2874
|
-
task_id: taskId,
|
|
2875
|
-
context_id: contextId,
|
|
2876
|
-
task_status: 'TASK_STATE_DELIVERED',
|
|
2877
|
-
artifacts: [],
|
|
2878
|
-
consumed: consumedProbe,
|
|
2879
|
-
receipt: receiptExpectation === 'process-label' ? 'none' : 'routed',
|
|
2880
|
-
});
|
|
2881
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2882
|
-
res.end(payload);
|
|
2883
|
-
}
|
|
2884
|
-
catch (error) {
|
|
2885
|
-
recordActivity('in', team, caller, false);
|
|
2886
|
-
const payload = JSON.stringify({ error: `The DSH session rejected the message: ${String(error)}`, code: -32000, team, task_id: taskId, task_status: 'TASK_STATE_FAILED' });
|
|
2887
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2888
|
-
res.end(payload);
|
|
2889
|
-
}
|
|
2890
|
-
};
|
|
2891
|
-
if (agent !== undefined) {
|
|
2892
|
-
deliver(agent);
|
|
2893
|
-
return;
|
|
2894
|
-
}
|
|
2895
|
-
if (wake?.kind === 'flight') {
|
|
2896
|
-
void wake.flight.then(deliver, (error) => {
|
|
2897
|
-
recordActivity('in', team, caller, false);
|
|
2898
|
-
const payload = JSON.stringify({ error: `waking the session for team "${team}" failed: ${String(error)}`, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
|
|
2899
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2900
|
-
res.end(payload);
|
|
2901
|
-
});
|
|
2902
|
-
return;
|
|
2903
|
-
}
|
|
2904
|
-
// Remaining candidates need one async classification (the
|
|
2905
|
-
// native-teams bridge), so the tail answers inside an IIFE.
|
|
2906
|
-
void (async () => {
|
|
2907
|
-
// Native-teams inbound bridge (opt-in): prepare BEFORE answering
|
|
2908
|
-
// delivered — claim, seam, and parent initiator are everything
|
|
2909
|
-
// that can fail fast, and a phantom dispatch must never answer
|
|
2910
|
-
// success. Past prepare, the round fires detached (noWait means
|
|
2911
|
-
// delivery, not settlement); its late outcome corrects the
|
|
2912
|
-
// activity ring and the log.
|
|
2913
|
-
const standardMiss = () => {
|
|
2914
|
-
if (team === config.team) {
|
|
2915
|
-
const live = liveAgent();
|
|
2916
|
-
if (live !== undefined) {
|
|
2917
|
-
// Self-route refusal (mirrors the sync face): a caller
|
|
2918
|
-
// naming the very agent the fallback resolves to must
|
|
2919
|
-
// get an honest failure, never its own dispatch echoed
|
|
2920
|
-
// home plus an auto-receipt for work nobody did.
|
|
2921
|
-
if (callerIsAgent(live, caller)) {
|
|
2922
|
-
recordActivity('in', team, caller, false);
|
|
2923
|
-
const payload = JSON.stringify({ error: selfRouteText(team), code: -32000, team, task_status: 'TASK_STATE_FAILED' });
|
|
2924
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2925
|
-
res.end(payload);
|
|
2926
|
-
return;
|
|
2927
|
-
}
|
|
2928
|
-
deliver(live);
|
|
2929
|
-
return;
|
|
2930
|
-
}
|
|
2931
|
-
}
|
|
2932
|
-
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' });
|
|
2933
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2934
|
-
res.end(payload);
|
|
2935
|
-
};
|
|
2936
|
-
const prepared = await nativeTeamsPrepare(team);
|
|
2937
|
-
if (!prepared.ok) {
|
|
2938
|
-
if (prepared.reason === 'error') {
|
|
2939
|
-
recordActivity('in', team, caller, false);
|
|
2940
|
-
const payload = JSON.stringify({ error: prepared.error, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
|
|
2941
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2942
|
-
res.end(payload);
|
|
2943
|
-
return;
|
|
2944
|
-
}
|
|
2945
|
-
standardMiss();
|
|
2946
|
-
return;
|
|
2947
|
-
}
|
|
2948
|
-
recordActivity('in', team, caller, true);
|
|
2949
|
-
void nativeTeamsRound(prepared, team, caller, message, taskId)
|
|
2950
|
-
.then(outcome => {
|
|
2951
|
-
if (!outcome.ok) {
|
|
2952
|
-
recordActivity('in', team, caller, false);
|
|
2953
|
-
logger.warn(`a2a: detached native-teams round for "${team}" failed: ${outcome.error}`);
|
|
2954
|
-
}
|
|
2955
|
-
// W7 slice-2 hook 2: only a genuinely settled detached round
|
|
2956
|
-
// records an outcome. The deadline's settled:false
|
|
2957
|
-
// projection is a placeholder, not a product — the same
|
|
2958
|
-
// ruling as hook 1's DELIVERED skip; placeholder prose never
|
|
2959
|
-
// enters the ledger a replay could adopt from.
|
|
2960
|
-
if (taskId !== '') {
|
|
2961
|
-
if (!outcome.ok)
|
|
2962
|
-
idempotencyStore.recordOutcome(taskId, { status: 'failed', error: outcome.error });
|
|
2963
|
-
else if (outcome.settled)
|
|
2964
|
-
idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: outcome.reply });
|
|
2965
|
-
}
|
|
2966
|
-
});
|
|
2967
|
-
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' });
|
|
2968
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2969
|
-
res.end(payload);
|
|
2970
|
-
})();
|
|
2971
|
-
return;
|
|
2972
|
-
}
|
|
2973
|
-
void routeIntoAgent(team, message, caller, taskId).then((outcome) => {
|
|
2974
|
-
recordActivity('in', team, caller, outcome.ok);
|
|
2975
|
-
// W7 slice-2 hook 1: record the settled outcome on the claim row.
|
|
2976
|
-
// Ruling: a non-COMPLETED status (TASK_STATE_DELIVERED — the
|
|
2977
|
-
// bridge-round deadline placeholder, ABORTED_WAIT likewise) is
|
|
2978
|
-
// NOT a settled outcome, and neither is a host-authored
|
|
2979
|
-
// placeholder on the reply channel (flush timeout, dead session)
|
|
2980
|
-
// — the row stays pending, because an adopted replay must never
|
|
2981
|
-
// settle a node on placeholder text (the exact hazard W7 gap B
|
|
2982
|
-
// closed, barred from resurrecting through the outcome ledger).
|
|
2983
|
-
if (taskId !== '') {
|
|
2984
|
-
if (!outcome.ok) {
|
|
2985
|
-
idempotencyStore.recordOutcome(taskId, { status: 'failed', error: outcome.error });
|
|
2986
|
-
}
|
|
2987
|
-
else if (outcome.placeholder === true) {
|
|
2988
|
-
// placeholder prose never enters the ledger
|
|
2989
|
-
}
|
|
2990
|
-
else if (outcome.status === undefined || outcome.status === 'TASK_STATE_COMPLETED') {
|
|
2991
|
-
idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: outcome.reply });
|
|
2992
|
-
}
|
|
2993
|
-
}
|
|
2994
|
-
const payload = outcome.ok
|
|
2995
|
-
? JSON.stringify({
|
|
2996
|
-
routed: true,
|
|
2997
|
-
team,
|
|
2998
|
-
session,
|
|
2999
|
-
result: { text: outcome.reply },
|
|
3000
|
-
task_id: taskId,
|
|
3001
|
-
context_id: contextId,
|
|
3002
|
-
task_status: outcome.status ?? 'TASK_STATE_COMPLETED',
|
|
3003
|
-
artifacts: [],
|
|
3004
|
-
// The bridge marker rides the wire so the caller's
|
|
3005
|
-
// trackOwedTask skips a receipt-less native round — without
|
|
3006
|
-
// it, a cross-node unsettled round books an unpayable owed
|
|
3007
|
-
// row and re-creates the TASK_CAP backlog.
|
|
3008
|
-
...(outcome.bridge !== undefined ? { bridge: outcome.bridge } : {}),
|
|
3009
|
-
})
|
|
3010
|
-
: JSON.stringify({ error: outcome.error, code: -32000, team, task_id: taskId, task_status: 'TASK_STATE_FAILED' });
|
|
3011
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3012
|
-
res.end(payload);
|
|
3013
|
-
});
|
|
3014
|
-
});
|
|
3015
|
-
},
|
|
3016
|
-
}), 'a2a: direct route endpoint');
|
|
3017
|
-
// 0.6.15: the read-only peer log face — a peer asks for THIS node's
|
|
3018
|
-
// session transcript tail (or, without id, the readable-session
|
|
3019
|
-
// listing) so a remote workbench can show "what has this node been
|
|
3020
|
-
// talking about". Admission is a2aCallerRefusal — exactly the rule
|
|
3021
|
-
// /a2a/direct applies to message delivery: caller_session required,
|
|
3022
|
-
// zone/id8 callers judged on shared teams. Read semantics are the
|
|
3023
|
-
// local /__dsh_a2a/session-log face verbatim (artifact existence is
|
|
3024
|
-
// the only gate; archived and never-joined sessions stay readable;
|
|
3025
|
-
// caps mirror session-transcript.ts). Nothing here steers a session,
|
|
3026
|
-
// claims a task, or writes state — the only side effects are the
|
|
3027
|
-
// activity-ring record and the admission path's background
|
|
3028
|
-
// directory learn.
|
|
3029
|
-
ctx.effect(() => webServer.register({
|
|
3030
|
-
kind: 'exact',
|
|
3031
|
-
path: '/a2a/log',
|
|
3032
|
-
handler: async (req, res) => {
|
|
3033
|
-
const send = (status, body) => {
|
|
3034
|
-
const payload = JSON.stringify(body);
|
|
3035
|
-
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3036
|
-
res.end(payload);
|
|
3037
|
-
};
|
|
3038
|
-
if (req.method !== 'GET') {
|
|
3039
|
-
send(405, { ok: false, error: 'GET is the only method on this face' });
|
|
3040
|
-
return;
|
|
3041
|
-
}
|
|
3042
|
-
// V2 (gh#7, S1): the read-only log face rides the same apiKey gate
|
|
3043
|
-
// as delivery — a configured key must be presented here too.
|
|
3044
|
-
if (config.apiKey !== '') {
|
|
3045
|
-
const presented = String(req.headers['x-api-key'] ?? '');
|
|
3046
|
-
if (!secretEqual(presented, config.apiKey)) {
|
|
3047
|
-
send(401, { ok: false, error: 'unauthorized: X-API-Key required (apiKey is configured)', code: -32000 });
|
|
3048
|
-
return;
|
|
3049
|
-
}
|
|
3050
|
-
}
|
|
3051
|
-
const url = new URL(req.url ?? '/', 'http://x');
|
|
3052
|
-
const caller = url.searchParams.get('caller_session') ?? '';
|
|
3053
|
-
const id = url.searchParams.get('id') ?? '';
|
|
3054
|
-
const limitArg = Number(url.searchParams.get('limit') ?? TRANSCRIPT_DEFAULT_LIMIT);
|
|
3055
|
-
const limit = Number.isFinite(limitArg) ? Math.max(1, Math.min(TRANSCRIPT_MAX_LIMIT, Math.floor(limitArg))) : TRANSCRIPT_DEFAULT_LIMIT;
|
|
3056
|
-
const refusal = a2aCallerRefusal(caller);
|
|
3057
|
-
if (refusal !== undefined) {
|
|
3058
|
-
recordActivity('in', 'log', caller === '' ? 'anonymous' : caller, false);
|
|
3059
|
-
send(200, { ok: false, error: refusal, code: -32000 });
|
|
3060
|
-
return;
|
|
3061
|
-
}
|
|
3062
|
-
// gh#9+gh#10: the log face reuses the exact OriginClaim
|
|
3063
|
-
// verification (§8.3) — same window semantics as delivery.
|
|
3064
|
-
const logClaim = await verifyOriginClaim(req);
|
|
3065
|
-
if (!logClaim.ok) {
|
|
3066
|
-
if (logClaim.reason !== 'absent' || config.originClaimWindow === false) {
|
|
3067
|
-
recordActivity('in', 'log', caller, false);
|
|
3068
|
-
send(200, { ok: false, error: `untrusted-origin: OriginClaim verification failed (${logClaim.reason}) — present a signed envelope or upgrade your node`, code: -32000 });
|
|
3069
|
-
return;
|
|
3070
|
-
}
|
|
3071
|
-
legacyInboundCount += 1;
|
|
3072
|
-
}
|
|
3073
|
-
recordActivity('in', 'log', caller, true);
|
|
3074
|
-
if (id.includes('/') || id.includes('\\') || id.includes('..')) {
|
|
3075
|
-
send(200, { ok: false, error: 'id is required' });
|
|
3076
|
-
return;
|
|
3077
|
-
}
|
|
3078
|
-
if (id === '') {
|
|
3079
|
-
try {
|
|
3080
|
-
const rows = scanSessionLogList();
|
|
3081
|
-
send(200, { ok: true, count: rows.length, sessions: rows.slice(0, 50) });
|
|
3082
|
-
}
|
|
3083
|
-
catch (error) {
|
|
3084
|
-
send(200, { ok: false, error: `transcript list failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
3085
|
-
}
|
|
3086
|
-
return;
|
|
3087
|
-
}
|
|
3088
|
-
try {
|
|
3089
|
-
const tail = readSessionLogTail(id, limit);
|
|
3090
|
-
if (!tail.ok)
|
|
3091
|
-
send(200, { ok: false, error: tail.error });
|
|
3092
|
-
else
|
|
3093
|
-
send(200, { ok: true, id: tail.id, count: tail.messages.length, messages: tail.messages });
|
|
3094
|
-
}
|
|
3095
|
-
catch (error) {
|
|
3096
|
-
send(200, { ok: false, error: `transcript failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
3097
|
-
}
|
|
3098
|
-
},
|
|
3099
|
-
}), 'a2a: peer log face endpoint');
|
|
3100
|
-
});
|
|
3101
|
-
whenWebServerSettled((webServer) => {
|
|
3102
|
-
// W7 slice 2: the S1 outcome-retrieval surface — a READ-ONLY lookup of
|
|
3103
|
-
// one claimed task's settled outcome. The body carries ONLY the task id
|
|
3104
|
-
// and the submit payload's fingerprint (peerPayloadFingerprint, the
|
|
3105
|
-
// gate's own shared implementation): the fingerprint match is the
|
|
3106
|
-
// authorization, and the fan-out probe never leaks the original message
|
|
3107
|
-
// to peers that never saw it. Constant-200 semantics — a query NEVER
|
|
3108
|
-
// produces a wire error code (the frozen -32002/-32003 vocabulary stays
|
|
3109
|
-
// submit-only), never claims, never steers, never enters a session: the
|
|
3110
|
-
// gate's "verdict before steer" has its mirror in "a query executes
|
|
3111
|
-
// nothing". Malformed fields degrade to unknown-task rather than
|
|
3112
|
-
// guessing; an unparseable body answers 400 like /a2a/direct.
|
|
3113
|
-
ctx.effect(() => webServer.register({
|
|
3114
|
-
kind: 'exact',
|
|
3115
|
-
path: '/a2a/query',
|
|
3116
|
-
handler: (req, res) => {
|
|
3117
|
-
const chunks = [];
|
|
3118
|
-
let size = 0;
|
|
3119
|
-
let overflowed = false;
|
|
3120
|
-
req.on('data', (chunk) => {
|
|
3121
|
-
if (overflowed)
|
|
3122
|
-
return;
|
|
3123
|
-
const next = size + chunk.length;
|
|
3124
|
-
if (next > 65_536) {
|
|
3125
|
-
overflowed = true;
|
|
3126
|
-
chunks.length = 0;
|
|
3127
|
-
return;
|
|
3128
|
-
}
|
|
3129
|
-
size = next;
|
|
3130
|
-
chunks.push(chunk);
|
|
3131
|
-
});
|
|
3132
|
-
req.on('error', () => {
|
|
3133
|
-
if (!res.headersSent) {
|
|
3134
|
-
res.writeHead(400);
|
|
3135
|
-
res.end();
|
|
3136
|
-
}
|
|
3137
|
-
});
|
|
3138
|
-
req.on('end', () => {
|
|
3139
|
-
if (overflowed) {
|
|
3140
|
-
if (!res.writableEnded && !res.headersSent) {
|
|
3141
|
-
const payload = JSON.stringify({ error: 'payload too large', code: WIRE_ERROR_PAYLOAD_TOO_LARGE });
|
|
3142
|
-
res.writeHead(413, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3143
|
-
res.end(payload);
|
|
3144
|
-
}
|
|
3145
|
-
return;
|
|
3146
|
-
}
|
|
3147
|
-
let body;
|
|
3148
|
-
try {
|
|
3149
|
-
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
3150
|
-
}
|
|
3151
|
-
catch {
|
|
3152
|
-
const payload = JSON.stringify({ error: 'malformed body', code: -32000 });
|
|
3153
|
-
res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3154
|
-
res.end(payload);
|
|
3155
|
-
return;
|
|
3156
|
-
}
|
|
3157
|
-
const taskId = typeof body.task_id === 'string' ? body.task_id : '';
|
|
3158
|
-
const fingerprint = typeof body.fingerprint === 'string' ? body.fingerprint : '';
|
|
3159
|
-
const verdict = taskId === '' || fingerprint === ''
|
|
3160
|
-
? { found: false, reason: 'unknown-task' }
|
|
3161
|
-
: idempotencyStore.query(taskId, fingerprint);
|
|
3162
|
-
const payload = JSON.stringify(verdict.found
|
|
3163
|
-
? verdict.status === 'pending'
|
|
3164
|
-
? { found: true, status: 'pending', task_id: taskId }
|
|
3165
|
-
: verdict.status === 'completed'
|
|
3166
|
-
? {
|
|
3167
|
-
found: true,
|
|
3168
|
-
status: 'completed',
|
|
3169
|
-
reply: verdict.reply,
|
|
3170
|
-
settled_at: new Date(verdict.settledAt).toISOString(),
|
|
3171
|
-
task_id: taskId,
|
|
3172
|
-
...(verdict.truncated === true ? { truncated: true } : {}),
|
|
3173
|
-
}
|
|
3174
|
-
: {
|
|
3175
|
-
found: true,
|
|
3176
|
-
status: 'failed',
|
|
3177
|
-
error: verdict.error,
|
|
3178
|
-
settled_at: new Date(verdict.settledAt).toISOString(),
|
|
3179
|
-
task_id: taskId,
|
|
3180
|
-
...(verdict.truncated === true ? { truncated: true } : {}),
|
|
3181
|
-
}
|
|
3182
|
-
: { found: false, reason: verdict.reason, task_id: taskId });
|
|
3183
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
3184
|
-
res.end(payload);
|
|
3185
|
-
});
|
|
3186
|
-
},
|
|
3187
|
-
}), '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
|
+
});
|
|
3188
2574
|
});
|
|
3189
2575
|
whenWebServerSettled((webServer) => {
|
|
3190
2576
|
// Task-ledger control: explicit caller-side abandonment of one owed
|
|
@@ -3668,7 +3054,16 @@ ${message}`;
|
|
|
3668
3054
|
function cachedCardFetch(url) {
|
|
3669
3055
|
const hit = cardCache.get(url);
|
|
3670
3056
|
const now = Date.now();
|
|
3671
|
-
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)) {
|
|
3672
3067
|
return Promise.resolve(hit.card);
|
|
3673
3068
|
}
|
|
3674
3069
|
const inFlight = cardCacheInFlight.get(url);
|
|
@@ -3676,6 +3071,17 @@ ${message}`;
|
|
|
3676
3071
|
return inFlight;
|
|
3677
3072
|
const fetch = fetchPeerCard(url)
|
|
3678
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
|
+
}
|
|
3679
3085
|
cardCache.set(url, { at: Date.now(), card });
|
|
3680
3086
|
return card;
|
|
3681
3087
|
})
|