@nelsonlongxiang/dsh-open-a2a-net 0.6.28 → 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 -624
- package/cordis.patch.yml +21 -21
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +744 -0
- package/lib/index.js +139 -737
- package/lib/recursion-guard.js +78 -0
- package/lib/runtime.js +1 -0
- package/lib/skill/SKILL.md +34 -9
- package/lib/skill/references/cli.md +24 -7
- package/lib/skill/references/discovery.md +5 -2
- package/lib/skill/references/pitfalls.md +2 -1
- package/lib/skill/references/routing.md +18 -8
- package/lib/skill/references/setup.md +152 -0
- package/lib/task-ledger.js +6 -0
- package/lib/types/faces/admin.d.ts +132 -0
- package/lib/types/index.d.ts +11 -3
- 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 -103
- package/nexus-stage/src/federation.ts +240 -240
- package/nexus-stage/src/layout-doc.ts +143 -143
- package/nexus-stage/src/layout-wire.ts +158 -158
- package/nexus-stage/src/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 +63 -88
- 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-1niOBYxp.js +0 -5
- package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
- package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursion guard (#60, first step: the hop budget) per
|
|
3
|
+
* docs/protocol/recursion-guard.md. State rides the payload: the origin
|
|
4
|
+
* mints the envelope with a full budget, every forwarded leg decrements,
|
|
5
|
+
* and a zero-budget arrival is refused with a STRUCTURED budget-exhausted
|
|
6
|
+
* error — silent swallowing is forbidden. The visited set (protocol §4.3)
|
|
7
|
+
* is the SECOND step and is deliberately absent here.
|
|
8
|
+
*
|
|
9
|
+
* Wire form: the X-A2A-Guard header carries a base64 JSON payload
|
|
10
|
+
* { visitToken, hopBudget, deadlineAt? }. An ABSENT header means a
|
|
11
|
+
* non-forwarding origin: the receiver grants the default one-shot budget
|
|
12
|
+
* and records guardMode 'default' (protocol §6 — old peers are
|
|
13
|
+
* constrained, not broken). Parse cost is O(1) for the budget path.
|
|
14
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/recursion-guard
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_HOP_BUDGET = 4;
|
|
17
|
+
/** Wire code for a zero-budget arrival (cluster-C registry, after -32006). */
|
|
18
|
+
export const WIRE_ERROR_BUDGET_EXHAUSTED = -32007;
|
|
19
|
+
/**
|
|
20
|
+
* Ingress check: parse the header, validate the budget, fail closed. A
|
|
21
|
+
* malformed payload falls back to the default budget (a guard that cannot
|
|
22
|
+
* be evaluated must not reject honest first-hop traffic; the budget bound
|
|
23
|
+
* still applies). hopBudget <= 0 on arrival ⇒ budget-exhausted.
|
|
24
|
+
*/
|
|
25
|
+
export function checkGuardAtIngress(header) {
|
|
26
|
+
if (header === undefined || header === '') {
|
|
27
|
+
return { kind: 'ok', guard: { visitToken: '', hopBudget: DEFAULT_HOP_BUDGET }, mode: 'default' };
|
|
28
|
+
}
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(Buffer.from(header, 'base64').toString('utf8'));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { kind: 'ok', guard: { visitToken: '', hopBudget: DEFAULT_HOP_BUDGET }, mode: 'default' };
|
|
35
|
+
}
|
|
36
|
+
const hopBudget = parsed?.hopBudget;
|
|
37
|
+
if (typeof hopBudget !== 'number' || !Number.isInteger(hopBudget)) {
|
|
38
|
+
return { kind: 'ok', guard: { visitToken: '', hopBudget: DEFAULT_HOP_BUDGET }, mode: 'default' };
|
|
39
|
+
}
|
|
40
|
+
if (hopBudget <= 0)
|
|
41
|
+
return { kind: 'budget-exhausted' };
|
|
42
|
+
const visitToken = typeof parsed.visitToken === 'string'
|
|
43
|
+
? String(parsed.visitToken)
|
|
44
|
+
: '';
|
|
45
|
+
const deadlineRaw = parsed.deadlineAt;
|
|
46
|
+
return {
|
|
47
|
+
kind: 'ok',
|
|
48
|
+
guard: { visitToken, hopBudget, ...(typeof deadlineRaw === 'number' ? { deadlineAt: deadlineRaw } : {}) },
|
|
49
|
+
mode: 'explicit',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Mint the outbound guard for a first-hop dispatch (full budget at origin). */
|
|
53
|
+
export function mintGuard() {
|
|
54
|
+
return {
|
|
55
|
+
visitToken: Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join(''),
|
|
56
|
+
hopBudget: DEFAULT_HOP_BUDGET,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Serialize for the X-A2A-Guard header. */
|
|
60
|
+
export function guardHeader(guard) {
|
|
61
|
+
return Buffer.from(JSON.stringify(guard), 'utf8').toString('base64');
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The ingress refusal path (gh#60): budget-exhausted answers the structured
|
|
65
|
+
* error, records the receiptless ledger row, and reports true (the caller
|
|
66
|
+
* returns). The whole response lives here so the ingress seam stays ONE
|
|
67
|
+
* line (the index.ts ratchet pays-as-it-goes: no net line growth).
|
|
68
|
+
*/
|
|
69
|
+
export function refuseIfBudgetExhausted(req, res, team, caller, taskId, ledger) {
|
|
70
|
+
const verdict = checkGuardAtIngress(req.headers['x-a2a-guard']);
|
|
71
|
+
if (verdict.kind !== 'budget-exhausted')
|
|
72
|
+
return false;
|
|
73
|
+
ledger.trackReceiptless(taskId, team, caller === '' ? 'unknown' : caller, undefined, 'budget-exhausted at ingress (hopBudget=0)');
|
|
74
|
+
const payload = JSON.stringify({ error: 'budget-exhausted: the hop budget is spent (recursion-guard #60) — this delivery traversed its full forwarding allowance and is refused at ingress', code: WIRE_ERROR_BUDGET_EXHAUSTED, team, task_status: 'TASK_STATE_FAILED' });
|
|
75
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
76
|
+
res.end(payload);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
package/lib/runtime.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/lib/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: a2a-network
|
|
3
|
-
description: Use when a task needs cross-session or cross-node collaboration over the DSH A2A network — joining and teaming, routing work to remote teams and agents, reconciling receipts and the task ledger, and reading the A2A workbench (/__dsh_a2a_nexus/). Covers admission rules, the receipt contract, and the failure shapes that look like bugs but are contract semantics.
|
|
3
|
+
description: Use when a task needs cross-session or cross-node collaboration over the DSH A2A network — setting it up (install, config, apiKey, peers, canvas links for one host, two peers, or hosts that cannot reach each other), joining and teaming, routing work to remote teams and agents, reconciling receipts and the task ledger, and reading the A2A workbench (/__dsh_a2a_nexus/). Covers admission rules, the receipt contract, and the failure shapes that look like bugs but are contract semantics.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# DSH A2A Network
|
|
@@ -27,6 +27,20 @@ surface on every node.
|
|
|
27
27
|
deposited on ONE home host for up to 4h, with a mailbox instead of a
|
|
28
28
|
wakeable session. See [cli.md](references/cli.md).
|
|
29
29
|
|
|
30
|
+
## Setting up
|
|
31
|
+
|
|
32
|
+
Start with [setup.md](references/setup.md). It is a checked path for three
|
|
33
|
+
network shapes:
|
|
34
|
+
- **one host**: install, join the sessions, give them a shared team;
|
|
35
|
+
- **two hosts that reach each other**: one shared `apiKey`, `announce` plus
|
|
36
|
+
peers seeded both ways, and a canvas team naming both handles;
|
|
37
|
+
- **hosts that cannot reach each other**: host forwarding is not shipped
|
|
38
|
+
yet (#57), so tunnel or delegate per hop.
|
|
39
|
+
|
|
40
|
+
The owner does the gestures (install, config, join, allowlist, canvas). The
|
|
41
|
+
agent prepares the exact config and verifies each step with `a2a_whoami`,
|
|
42
|
+
`a2a_probe`, `a2a_teams` and one round with a receipt.
|
|
43
|
+
|
|
30
44
|
## Routing (a2a_route)
|
|
31
45
|
|
|
32
46
|
```text
|
|
@@ -36,10 +50,15 @@ a2a_route { team: "<zone>/<id8 or canvas name>", message, context_id?, async? }
|
|
|
36
50
|
- `async: true` delivers without waiting (`delivered:true` + task id); the
|
|
37
51
|
target routes a receipt — a message starting `[A2A receipt] task <id>` —
|
|
38
52
|
back to your team.
|
|
39
|
-
- Sync (default) waits for the round's final answer.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
53
|
+
- Sync (default) waits for the round's final answer. A target on this host
|
|
54
|
+
gets up to 180s; past that the tool answers the delivered shape
|
|
55
|
+
(`TASK_STATE_DELIVERED`) and the receipt contract carries the rest. A
|
|
56
|
+
remote peer's delivery rides a 15s transport budget; if it fires first, the
|
|
57
|
+
result is **-32005 DELIVERED-UNSETTLED**: the delivery landed and the peer
|
|
58
|
+
keeps executing. Either way, **never re-dispatch blindly** — reconcile via
|
|
59
|
+
the task id (the receipt, `a2a_status` activity, or `a2a_tasks`) instead.
|
|
60
|
+
- Model tools cannot route to the bare process team (`<zone>` alone, the
|
|
61
|
+
host's own mailbox): target a session handle or a canvas team.
|
|
43
62
|
- `context_id` from a previous reply continues that conversation.
|
|
44
63
|
|
|
45
64
|
## Membership rules
|
|
@@ -55,10 +74,15 @@ a2a_route { team: "<zone>/<id8 or canvas name>", message, context_id?, async? }
|
|
|
55
74
|
|
|
56
75
|
## Reading the state
|
|
57
76
|
|
|
58
|
-
`__dsh_a2a/state` (per node
|
|
59
|
-
`
|
|
60
|
-
|
|
61
|
-
|
|
77
|
+
`__dsh_a2a/state` (per node, key-gated like every control route) carries
|
|
78
|
+
`sessions` (live/joined, with their teams), `canvas` (canvas teams; remote
|
|
79
|
+
members show `remote: true`), `remote` (peer session rows with `origin` =
|
|
80
|
+
node label + LAN IP and `via` = the peer URL), `activity` (routing ring),
|
|
81
|
+
and the ledger: `tasks` (pending, each with `settleable`),
|
|
82
|
+
`tasksStaleCount`, `tasksDead`, and `archived` (newest 64 settled rows, each
|
|
83
|
+
with `outcome`, `endedBy` and `late`) plus `archivedCount` /
|
|
84
|
+
`archivedSplit`. `sessionTeams` is a card field, not a state field. The A2A
|
|
85
|
+
workbench (`/__dsh_a2a_nexus/`, merged responsive
|
|
62
86
|
shell since 0.6.2 — the 2D planning board is the stage, directory / ledger /
|
|
63
87
|
activity / plan ride a collapsible rail, narrow screens get a drawer)
|
|
64
88
|
renders the same data for humans.
|
|
@@ -82,6 +106,7 @@ renders the same data for humans.
|
|
|
82
106
|
|
|
83
107
|
## Details
|
|
84
108
|
|
|
109
|
+
- [setup.md](references/setup.md) — setup and checks: one host, two peers, beyond direct peers
|
|
85
110
|
- [routing.md](references/routing.md) — receipts, idempotency, ledger anatomy
|
|
86
111
|
- [discovery.md](references/discovery.md) — cards, peer boundary model, invisibility
|
|
87
112
|
- [membership.md](references/membership.md) — join/leave, allowlist, canvas teams
|
|
@@ -24,7 +24,10 @@ ten model tools.
|
|
|
24
24
|
|
|
25
25
|
- `a2a-net publish` deposits the signed CLI card on the home host
|
|
26
26
|
(`/__dsh_a2a/cli-deposit`, a control route: key-gated like every write).
|
|
27
|
-
The deposit lives at most 4h
|
|
27
|
+
The deposit lives at most 4h. An expired deposit is no longer routable and
|
|
28
|
+
its card is no longer served, but it stays in the host's directory as an
|
|
29
|
+
OFFLINE row (gh#47; the newest 16 are kept) so canvas refs to it never
|
|
30
|
+
dangle silently. Publish again (same handle, same key) to come back.
|
|
28
31
|
- While deposited, the host serves the card at
|
|
29
32
|
`<home>/a2a/cli/<handle>/.well-known/agent-card.json` and lists the handle
|
|
30
33
|
in its directory as "cli — deposited identity (no wake; mailbox)".
|
|
@@ -36,8 +39,14 @@ ten model tools.
|
|
|
36
39
|
|
|
37
40
|
## Sending
|
|
38
41
|
|
|
39
|
-
- `a2a-net route <team> <message…>` delivers
|
|
40
|
-
OriginClaim envelope to the HOME host's `/a2a/direct`.
|
|
42
|
+
- `a2a-net route <team> <message…>` delivers a round with a signed
|
|
43
|
+
OriginClaim envelope to the HOME host's `/a2a/direct`. It is async by
|
|
44
|
+
default: the receipt comes back to `a2a-net inbox`.
|
|
45
|
+
- `--sync` waits for the target's reply and prints it:
|
|
46
|
+
`reply from <team> · task <id> · context <ctx>` followed by the reply text.
|
|
47
|
+
When the round has not settled (status other than completed) a warning
|
|
48
|
+
goes to stderr: reconcile by task id before re-sending.
|
|
49
|
+
- `--context ID` continues that conversation (sends it as `context_id`).
|
|
41
50
|
- Direct delivery does not relay: only teams on the home host are reachable.
|
|
42
51
|
A team on another host answers "No live DSH session node accepts team".
|
|
43
52
|
- Admission: a deposited handle is a node fact of its home host (N3). It
|
|
@@ -45,8 +54,8 @@ ten model tools.
|
|
|
45
54
|
- `a2a-net report <message…>` is a one-way report to the host's process team.
|
|
46
55
|
- A message that starts with `-` goes after `--`. Flag values never become
|
|
47
56
|
part of the message.
|
|
48
|
-
-
|
|
49
|
-
prints the raw reply.
|
|
57
|
+
- Async output is one line (`delivered → <team> · task <id> · <receipt>`);
|
|
58
|
+
`--json` prints the raw reply.
|
|
50
59
|
|
|
51
60
|
## Receiving: the mailbox
|
|
52
61
|
|
|
@@ -71,8 +80,16 @@ ten model tools.
|
|
|
71
80
|
## Reading the host
|
|
72
81
|
|
|
73
82
|
`whoami`, `teams [query]`, `status`, `tasks`, `probe` read the home host's
|
|
74
|
-
state face (key-gated on a keyed host). `
|
|
75
|
-
|
|
83
|
+
state face (key-gated on a keyed host). `--json` prints raw rows for
|
|
84
|
+
`teams`, `tasks`, `probe`, `route`, `report`, `inbox` and `skills`.
|
|
85
|
+
|
|
86
|
+
## Local commands
|
|
87
|
+
|
|
88
|
+
- `a2a-net skills [name] [--root DIR] [--json]` lists the skills under the
|
|
89
|
+
local skill directory (default `~/.agents/skills`), or prints one by name.
|
|
90
|
+
It has no model-tool counterpart.
|
|
91
|
+
- `a2a-net tools` prints the CLI ↔ model-tool alignment table; `help` the
|
|
92
|
+
usage.
|
|
76
93
|
|
|
77
94
|
## Exit codes
|
|
78
95
|
|
|
@@ -10,8 +10,11 @@ capability flags) plus unsigned serve-fresh fields.
|
|
|
10
10
|
- The full card goes only to a caller that presents an OriginClaim envelope
|
|
11
11
|
(`X-A2A-Caller` / `-Url` / `-Ts` / `-Sig`, signed with the caller's card
|
|
12
12
|
key) AND that this host links: the same origin (self-fetch), one of its
|
|
13
|
-
joined session handles,
|
|
14
|
-
added (#18, #19 D1)
|
|
13
|
+
joined session handles, an exact canvas remote member ref the operator
|
|
14
|
+
added (#18, #19 D1), or bilateral seeding — this host's `peers` config
|
|
15
|
+
names the caller's origin AND the caller's card lists this host's origin
|
|
16
|
+
among its peers (both operators made the gesture). Proving a key is not a
|
|
17
|
+
link.
|
|
15
18
|
- Peers poll cards and aggregate the `sessionTeams` they are entitled to into
|
|
16
19
|
their `remote` rows — this is how the planning canvas shows other nodes.
|
|
17
20
|
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
the host that receives it; a team on another host answers "No live DSH
|
|
25
25
|
session node accepts team". `a2a_route` resolves peers and forwards;
|
|
26
26
|
direct callers (the CLI included) must target their home host's teams.
|
|
27
|
-
- A sync route
|
|
27
|
+
- A sync route to a remote peer rides a 15s transport budget (a target on
|
|
28
|
+
this host gets 180s) — both are shorter than many model turns. For
|
|
28
29
|
anything that runs long, prefer `async: true` and the receipt contract.
|
|
29
30
|
|
|
30
31
|
## Names and prefixes
|
|
@@ -17,14 +17,24 @@ polling `a2a_teams` cannot see another host's ledger.
|
|
|
17
17
|
|
|
18
18
|
## The ledger
|
|
19
19
|
|
|
20
|
-
`__dsh_a2a/state`
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
marker is waiting on the target's turn, not lost.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
20
|
+
`__dsh_a2a/state` splits the ledger into separate fields:
|
|
21
|
+
|
|
22
|
+
- **`tasks`** — pending rows: routed, receipt still owed. An owed task with a
|
|
23
|
+
delivered marker is waiting on the target's turn, not lost. Each row
|
|
24
|
+
carries `settleable` (young, expected and answerable).
|
|
25
|
+
- **`tasksStaleCount`** — how many pending rows are past the one-hour
|
|
26
|
+
settleable window or expect no receipt: conversational tail, not debt.
|
|
27
|
+
- **`tasksDead`** — past the stale TTL (`taskStaleTtlMs`, 24h), auto-dead-
|
|
28
|
+
lettered, with `reason` and `direction` when known. A dead task that
|
|
29
|
+
actually completed at the peer is a reconciliation candidate, not a rerun;
|
|
30
|
+
a late receipt still settles it.
|
|
31
|
+
- **`archived`** — rows that left the ledger, newest 64, newest first. Each
|
|
32
|
+
has `outcome` and `endedBy`: `receipt` (a correlated receipt), `echo`,
|
|
33
|
+
`abandoned` or `cancelled` (the caller ended it through
|
|
34
|
+
`/__dsh_a2a/tasks/abandon` or `/tasks/cancel`). `late: true` marks a
|
|
35
|
+
receipt that arrived after the caller ended the row; `direction` appears
|
|
36
|
+
when known. `archivedCount` and `archivedSplit` (count per outcome) cover
|
|
37
|
+
the whole archive.
|
|
28
38
|
|
|
29
39
|
## Idempotency
|
|
30
40
|
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Setup: one host, two peers, and beyond
|
|
2
|
+
|
|
3
|
+
A step-by-step path from a fresh install to a working round on three shapes
|
|
4
|
+
of network. Each step says WHO acts (owner or agent) and how to CHECK it
|
|
5
|
+
before moving on: most "it doesn't route" reports are a skipped check.
|
|
6
|
+
|
|
7
|
+
**Owner acts vs agent acts.** Installing the plugin, writing config,
|
|
8
|
+
restarting, joining a session to the network, adding exact names to
|
|
9
|
+
`teamJoinAllowlist`, and building canvas teams are human acts (the network
|
|
10
|
+
is human-established, gh#7 N1 / gh#11 N7). An agent prepares the exact
|
|
11
|
+
config, asks the owner to apply it, then verifies with the model tools. Never
|
|
12
|
+
drive the loopback control routes from a shell to do the owner's part:
|
|
13
|
+
presenting the operator's gesture on the operator's behalf is exactly the
|
|
14
|
+
#9 hole.
|
|
15
|
+
|
|
16
|
+
## 0. Preflight
|
|
17
|
+
|
|
18
|
+
| Check | How | Expect |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| Plugin present | `a2a_whoami` | `ok: true`. `face: session` inside a session, `host` otherwise |
|
|
21
|
+
| Version | `/__dsh_a2a/state` `version`, or the workbench header badge | the same version on every host you will link (0.6.28+) |
|
|
22
|
+
| Clock | `date` on each host | NTP-synced: card signatures carry `expiresAt`, and the OriginClaim envelope allows ±5 min |
|
|
23
|
+
|
|
24
|
+
## 1. One host: sessions talking to each other
|
|
25
|
+
|
|
26
|
+
**Owner:**
|
|
27
|
+
1. Install, then restart `dsh web`:
|
|
28
|
+
`dsh plugin --profile web add @nelsonlongxiang/dsh-open-a2a-net`
|
|
29
|
+
2. Leave the config at its defaults. `sessionNodes: true` exposes main
|
|
30
|
+
sessions as joinable nodes; no `apiKey` is needed while the listener is
|
|
31
|
+
bound to loopback.
|
|
32
|
+
3. Join each session that should collaborate: sidebar → A2A 网络 → toggle.
|
|
33
|
+
An unjoined session is invisible and refused (see
|
|
34
|
+
[membership.md](membership.md)).
|
|
35
|
+
4. Give the sessions a shared team (`teamScopeRouting` is on by default, so a
|
|
36
|
+
teamless session has no network). Pick ONE of:
|
|
37
|
+
- **Canvas team (recommended):** workbench `/__dsh_a2a_nexus/` → select
|
|
38
|
+
the session cards → 建队. Canvas membership is assigned by hand.
|
|
39
|
+
- **Roster team:** add the EXACT name (for example `dsh/review`) to
|
|
40
|
+
`teamJoinAllowlist` in the workbench owner panel (header ⚙). Each
|
|
41
|
+
session then declares it with `a2a_team_join { team: "dsh/review" }`.
|
|
42
|
+
A `*`/prefix entry authorizes nothing (`[PATTERN_NO_MINT]`).
|
|
43
|
+
|
|
44
|
+
**Agent, verify:**
|
|
45
|
+
- `a2a_whoami`: `node.joined: true`, the handle `<zone>/<id8>`, and the
|
|
46
|
+
team under `teams.roster` or `teams.canvas`.
|
|
47
|
+
- `a2a_teams`: the other session's handle is listed.
|
|
48
|
+
- One sync round:
|
|
49
|
+
`a2a_route { team: "<other handle>", message: "ping: reply 'pong'" }`.
|
|
50
|
+
A local target gets up to 180 s. Continue the conversation with the
|
|
51
|
+
returned `context_id`.
|
|
52
|
+
|
|
53
|
+
## 2. Two hosts that can reach each other (peers)
|
|
54
|
+
|
|
55
|
+
Do section 1 on both hosts first. Then the **owner**, on BOTH hosts:
|
|
56
|
+
|
|
57
|
+
1. **Reachability, both ways.** Each host must open the other's web
|
|
58
|
+
listener (LAN, VPN or tunnel). There is no built-in NAT traversal.
|
|
59
|
+
2. **One shared `apiKey`.** Bind beyond loopback only with a key. The
|
|
60
|
+
delivery face `/a2a/direct` and the log face require `X-API-Key` when a
|
|
61
|
+
key is set, and a host sends ITS OWN `apiKey` on every peer request. So
|
|
62
|
+
keyed hosts that talk to each other must carry the SAME key (or both
|
|
63
|
+
none, loopback-only). A mismatch shows up as HTTP 401
|
|
64
|
+
`X-API-Key required on the delivery face`.
|
|
65
|
+
3. **Announce and seed each other.** Seeding BOTH ways is what makes the
|
|
66
|
+
hosts linked: each side then reads the other's full card. One-sided
|
|
67
|
+
seeding does not link.
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
- id: a2a
|
|
71
|
+
name: '@nelsonlongxiang/dsh-open-a2a-net'
|
|
72
|
+
config:
|
|
73
|
+
announce: true
|
|
74
|
+
apiKey: '<same secret on every linked host>'
|
|
75
|
+
peers: ['http://<other-host>:<port>'] # the other side lists this host
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Restart. `announce` toggled from the owner panel applies on the next
|
|
79
|
+
restart.
|
|
80
|
+
4. **Link the sessions across hosts with a canvas team.** Hosts default to
|
|
81
|
+
the same zone name (`team: 'dsh'`), so a remote caller `dsh/<id8>`
|
|
82
|
+
resolves to no local session. A roster declaration cannot admit it,
|
|
83
|
+
because the roster holds this host's own sessions only. The record that
|
|
84
|
+
admits it is a **canvas team naming both exact handles**. Build it in the
|
|
85
|
+
workbench (建队 with the local session, then add the remote handle):
|
|
86
|
+
- on the host that receives rounds, or
|
|
87
|
+
- on either host, once they are linked (step 3).
|
|
88
|
+
|
|
89
|
+
The other side then reads that board's `canvasOrg` from the peer's full
|
|
90
|
+
card it has cached (gh#71, recognized both ways). `a2a_probe` warms that
|
|
91
|
+
cache. Receipts flowing back are admitted the same way.
|
|
92
|
+
|
|
93
|
+
Admission is target-scoped (F5): the remote member reaches only the
|
|
94
|
+
sessions inside that canvas team. A linked peer can also edit your canvas
|
|
95
|
+
through `/__dsh_a2a/fleet-canvas-op`; your host's link gate decides.
|
|
96
|
+
|
|
97
|
+
**Agent, verify on each host:**
|
|
98
|
+
- `a2a_probe`: the peer is reachable. A miss is classified as `unreachable`
|
|
99
|
+
(network, port, tunnel) or `rejected` (key, signature, clock).
|
|
100
|
+
- State face `remote` rows: the peer's sessions appear, with `via` set to
|
|
101
|
+
the peer URL. `a2a_teams` shows peer rows only for teams in this host's
|
|
102
|
+
joined scope (S5, `teamScopeInfo`), so an empty list is not proof that the
|
|
103
|
+
link failed.
|
|
104
|
+
- One async round with a receipt:
|
|
105
|
+
`a2a_route { team: "<remote handle>", message: "...", async: true }`,
|
|
106
|
+
then check `a2a_tasks` until the row lands in `archived` with
|
|
107
|
+
`endedBy: receipt`. A peer delivery rides a 15 s transport budget:
|
|
108
|
+
`-32005` means it was delivered and is still running. Reconcile; never
|
|
109
|
+
resend.
|
|
110
|
+
|
|
111
|
+
## 3. Beyond direct peers (A cannot reach C)
|
|
112
|
+
|
|
113
|
+
Host-to-host forwarding is **not shipped yet**. Today a delivery reaches only
|
|
114
|
+
the host it is sent to:
|
|
115
|
+
- `/a2a/direct` never relays.
|
|
116
|
+
- A session's `a2a_route` dials the host that publishes the team.
|
|
117
|
+
- #57 Plan A (host forwarding) is designed. Its prerequisite #60 hop budget
|
|
118
|
+
has landed at ingress.
|
|
119
|
+
|
|
120
|
+
Until forwarding ships, pick one:
|
|
121
|
+
|
|
122
|
+
1. **Make it a peer pair.** Open a tunnel or port-forward so A reaches C,
|
|
123
|
+
then do section 2 for A↔C. This is the simplest and keeps receipts
|
|
124
|
+
end-to-end.
|
|
125
|
+
2. **Delegate through B.** Route the task to a session on B, the host both
|
|
126
|
+
sides reach, and have that session route on to C and report back. Each
|
|
127
|
+
hop is its own task with its own receipt: B's ledger owns the B→C leg.
|
|
128
|
+
Keep messages self-contained and use `async: true` per hop.
|
|
129
|
+
3. **Names across zones.** `delegates` publishes signed name records, so a
|
|
130
|
+
name can resolve through other zones (≤5 hops, loops fail closed). That
|
|
131
|
+
is name resolution, not message forwarding: the final delivery still
|
|
132
|
+
goes straight to the resolved host, so that host must be reachable.
|
|
133
|
+
|
|
134
|
+
## 4. Callers outside a session
|
|
135
|
+
|
|
136
|
+
Scripts and terminals use the `a2a-net` CLI: deposit on ONE home host, read
|
|
137
|
+
a mailbox, no wake. The same rule applies: the CLI reaches only teams on its
|
|
138
|
+
home host. See [cli.md](cli.md).
|
|
139
|
+
|
|
140
|
+
## 5. Symptom → step
|
|
141
|
+
|
|
142
|
+
| Symptom | Go back to |
|
|
143
|
+
|---|---|
|
|
144
|
+
| `declares no team membership` / `shares no team` | §1.4 (same host) or §2.4 (cross-host canvas ref on the target) |
|
|
145
|
+
| `caller_session is required` | the caller is not a joined session (§1.3) or an external client omitted it |
|
|
146
|
+
| HTTP 401 `X-API-Key required` | §2.2: keys differ between the hosts |
|
|
147
|
+
| `a2a_probe` → `rejected` | §2.2 key, or §0 clock; also card signature/expiry |
|
|
148
|
+
| `a2a_probe` → `unreachable` | §2.1 reachability (bind address, firewall, tunnel) |
|
|
149
|
+
| peer reachable but its sessions never appear | §2.3: seeding is one-sided, or the peer did not `announce` |
|
|
150
|
+
| `No live DSH session node accepts team X` | X is on another host and nothing forwards (§3), or X is not joined/live there |
|
|
151
|
+
| `[PATTERN_NO_MINT]` / `[ALLOWLIST_DENY]` | §1.4: add the EXACT team name to the allowlist |
|
|
152
|
+
| `[CANVAS_TEAM]` from `a2a_team_join` | canvas teams are built in the workbench, not joined by tool |
|
package/lib/task-ledger.js
CHANGED
|
@@ -272,6 +272,7 @@ export class TaskLedger {
|
|
|
272
272
|
outcome: 'abandoned',
|
|
273
273
|
endedBy: 'abandoned',
|
|
274
274
|
...(record.direction !== undefined ? { direction: record.direction } : {}),
|
|
275
|
+
...(record.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
275
276
|
});
|
|
276
277
|
this.persist();
|
|
277
278
|
return { outcome: 'cleared', taskId, archivedAt: resolvedAt };
|
|
@@ -313,6 +314,7 @@ export class TaskLedger {
|
|
|
313
314
|
outcome: 'abandoned',
|
|
314
315
|
endedBy: 'cancelled',
|
|
315
316
|
...(record.direction !== undefined ? { direction: record.direction } : {}),
|
|
317
|
+
...(record.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
316
318
|
});
|
|
317
319
|
this.persist();
|
|
318
320
|
return {
|
|
@@ -363,6 +365,7 @@ export class TaskLedger {
|
|
|
363
365
|
...(elapsedMs !== undefined ? { elapsedMs } : {}),
|
|
364
366
|
endedBy: 'receipt',
|
|
365
367
|
...(record.direction !== undefined ? { direction: record.direction } : {}),
|
|
368
|
+
...(record.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
366
369
|
});
|
|
367
370
|
this.persist();
|
|
368
371
|
return {
|
|
@@ -445,6 +448,7 @@ export class TaskLedger {
|
|
|
445
448
|
summary: ECHO_SUMMARY,
|
|
446
449
|
endedBy: 'echo',
|
|
447
450
|
...(record.direction !== undefined ? { direction: record.direction } : {}),
|
|
451
|
+
...(record.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
448
452
|
});
|
|
449
453
|
settled.push(record.taskId);
|
|
450
454
|
}
|
|
@@ -505,6 +509,7 @@ export class TaskLedger {
|
|
|
505
509
|
...(typeof entry.lateReceiptAt === 'number' && Number.isFinite(entry.lateReceiptAt) ? { lateReceiptAt: entry.lateReceiptAt } : {}),
|
|
506
510
|
...(typeof entry.endedBy === 'string' && ARCHIVE_ENDS.includes(entry.endedBy) ? { endedBy: entry.endedBy } : {}),
|
|
507
511
|
...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
|
|
512
|
+
...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
508
513
|
});
|
|
509
514
|
continue;
|
|
510
515
|
}
|
|
@@ -550,6 +555,7 @@ export class TaskLedger {
|
|
|
550
555
|
...(typeof entry.lateReceiptAt === 'number' && Number.isFinite(entry.lateReceiptAt) ? { lateReceiptAt: entry.lateReceiptAt } : {}),
|
|
551
556
|
...(typeof entry.endedBy === 'string' && ARCHIVE_ENDS.includes(entry.endedBy) ? { endedBy: entry.endedBy } : {}),
|
|
552
557
|
...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
|
|
558
|
+
...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
553
559
|
});
|
|
554
560
|
}
|
|
555
561
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
3
|
+
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
4
|
+
import type WebServer from '@deepseek-ai/dsh-host-webserver';
|
|
5
|
+
import type { Config } from '../index.ts';
|
|
6
|
+
import type { CardCacheEntry, InFlightRoute, RouteActivityEntry } from '../runtime.ts';
|
|
7
|
+
import { CanvasStore } from '../canvas-store.ts';
|
|
8
|
+
import type { CliDepositStore } from '../cli-deposit.ts';
|
|
9
|
+
import type { ExposureStore } from '../exposure-store.ts';
|
|
10
|
+
import type { GroupStore } from '../group-store.ts';
|
|
11
|
+
import type { IdempotencyStore } from '../idempotency-store.ts';
|
|
12
|
+
import type { JoinedSessions } from '../joined-store.ts';
|
|
13
|
+
import type { LayoutStore } from '../layout-store.ts';
|
|
14
|
+
import type { PeerStore } from '../peer-store.ts';
|
|
15
|
+
import { type TaskLedger } from '../task-ledger.ts';
|
|
16
|
+
import type { TeamMembershipStore } from '../team-store.ts';
|
|
17
|
+
/** Remote-team rows cached for the state face (see refreshRemoteRows). */
|
|
18
|
+
export type RemoteRowsCache = {
|
|
19
|
+
at: number;
|
|
20
|
+
rows: {
|
|
21
|
+
team: string;
|
|
22
|
+
name: string;
|
|
23
|
+
origin?: string;
|
|
24
|
+
via?: string;
|
|
25
|
+
workspace?: string;
|
|
26
|
+
legacy?: boolean;
|
|
27
|
+
teams?: readonly string[];
|
|
28
|
+
}[];
|
|
29
|
+
} | undefined;
|
|
30
|
+
/** Everything the admin faces used to capture from `apply()`. */
|
|
31
|
+
export interface AdminFaceDeps {
|
|
32
|
+
readonly ctx: Context;
|
|
33
|
+
readonly config: Config;
|
|
34
|
+
readonly logger: ReturnType<Context['logger']>;
|
|
35
|
+
readonly home: string;
|
|
36
|
+
readonly session: string;
|
|
37
|
+
readonly lanIp: string;
|
|
38
|
+
readonly PLUGIN_VERSION: string;
|
|
39
|
+
/** index.ts's `import.meta.url` — the base the asset roots resolve against. */
|
|
40
|
+
readonly indexUrl: string;
|
|
41
|
+
readonly controlRoute: (handler: (req: IncomingMessage, res: ServerResponse) => void) => (req: IncomingMessage, res: ServerResponse) => void;
|
|
42
|
+
readonly controlTrustedWithoutKey: (req: IncomingMessage) => boolean;
|
|
43
|
+
readonly readJsonBody: (req: IncomingMessage, res: ServerResponse, use: (body: {
|
|
44
|
+
readonly id?: unknown;
|
|
45
|
+
readonly name?: unknown;
|
|
46
|
+
readonly action?: unknown;
|
|
47
|
+
}) => void, maxBytes?: number) => void;
|
|
48
|
+
readonly peerStore: PeerStore;
|
|
49
|
+
readonly peerLinkState: (peerUrl: string) => {
|
|
50
|
+
link: 'linked' | 'stranger';
|
|
51
|
+
verified: boolean;
|
|
52
|
+
};
|
|
53
|
+
readonly originClaimHeaders: () => Record<string, string>;
|
|
54
|
+
readonly refreshRemoteRows: () => void;
|
|
55
|
+
readonly joinedSessions: JoinedSessions;
|
|
56
|
+
readonly exposureStore: ExposureStore;
|
|
57
|
+
readonly teamMemberships: TeamMembershipStore;
|
|
58
|
+
readonly cliDeposits: CliDepositStore;
|
|
59
|
+
readonly groupStore: GroupStore;
|
|
60
|
+
readonly canvasStore: CanvasStore;
|
|
61
|
+
readonly layoutStore: LayoutStore;
|
|
62
|
+
readonly taskLedger: TaskLedger;
|
|
63
|
+
readonly idempotencyStore: IdempotencyStore;
|
|
64
|
+
readonly inFlightRoutes: Map<number, InFlightRoute>;
|
|
65
|
+
readonly recentActivity: RouteActivityEntry[];
|
|
66
|
+
readonly teamAuditRing: {
|
|
67
|
+
at: number;
|
|
68
|
+
action: 'join' | 'leave' | 'archive-prune' | 'agent-less';
|
|
69
|
+
team: string;
|
|
70
|
+
id: string;
|
|
71
|
+
ok: boolean;
|
|
72
|
+
reason?: string;
|
|
73
|
+
}[];
|
|
74
|
+
readonly reportRing: {
|
|
75
|
+
at: number;
|
|
76
|
+
level: 'info' | 'warning' | 'error';
|
|
77
|
+
message: string;
|
|
78
|
+
session: string;
|
|
79
|
+
task?: string;
|
|
80
|
+
}[];
|
|
81
|
+
readonly sessionNodes: Map<string, Agent>;
|
|
82
|
+
readonly liveRoots: Map<string, Agent>;
|
|
83
|
+
readonly mountSessionNode: (agent: Agent) => void;
|
|
84
|
+
readonly coldJoinedIds: () => Promise<Set<string>>;
|
|
85
|
+
readonly prewarmStatus: {
|
|
86
|
+
state: 'off' | 'skipped:no-wake-face' | 'draining' | 'done' | 'cancelled';
|
|
87
|
+
attempted: number;
|
|
88
|
+
woken: number;
|
|
89
|
+
failed: Array<{
|
|
90
|
+
id: string;
|
|
91
|
+
error: string;
|
|
92
|
+
}>;
|
|
93
|
+
};
|
|
94
|
+
readonly reconcileStatus: {
|
|
95
|
+
state: 'off' | 'idle' | 'draining';
|
|
96
|
+
lastTickAt: number;
|
|
97
|
+
lastChecked: number;
|
|
98
|
+
woken: number;
|
|
99
|
+
rows: Array<{
|
|
100
|
+
id: string;
|
|
101
|
+
error: string;
|
|
102
|
+
attempts: number;
|
|
103
|
+
nextRetryAt: number;
|
|
104
|
+
needsRepair: boolean;
|
|
105
|
+
}>;
|
|
106
|
+
};
|
|
107
|
+
readonly id8: (id: string) => string;
|
|
108
|
+
readonly sessionLabelOf: (agent: Agent) => string;
|
|
109
|
+
readonly sessionTeamOf: (agent: Agent) => string;
|
|
110
|
+
readonly nodeMetadataOf: (agent: Agent) => {
|
|
111
|
+
name: string;
|
|
112
|
+
description: string;
|
|
113
|
+
workspace?: string;
|
|
114
|
+
};
|
|
115
|
+
readonly canvasTeamOf: (name: string) => string;
|
|
116
|
+
readonly archivedSessionFilter: () => ((id: string) => boolean) | undefined;
|
|
117
|
+
readonly pruneArchivedJoins: () => void;
|
|
118
|
+
readonly pruneCanvasMemberships: () => void;
|
|
119
|
+
readonly cardCache: Map<string, CardCacheEntry>;
|
|
120
|
+
readonly remoteRowsCache: RemoteRowsCache;
|
|
121
|
+
readonly legacyInboundCount: number;
|
|
122
|
+
readonly ownCardUrl: string;
|
|
123
|
+
ownerAllowlist: string[];
|
|
124
|
+
ownerTeamScopeInfo: boolean;
|
|
125
|
+
ownerAnnounceRestartPending: boolean;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Register the admin faces on a settled web server.
|
|
129
|
+
* @param webServer - the settled host web server.
|
|
130
|
+
* @param d - the captures listed on {@link AdminFaceDeps}.
|
|
131
|
+
*/
|
|
132
|
+
export declare function registerAdminFaces(webServer: WebServer, d: AdminFaceDeps): void;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -191,11 +191,19 @@ export interface Config {
|
|
|
191
191
|
/** Backoff ceiling; corrupt-log rows park as needs-repair and never retry. */
|
|
192
192
|
readonly wakeReconcileMaxBackoffMs: number;
|
|
193
193
|
/**
|
|
194
|
-
* S4:
|
|
195
|
-
*
|
|
194
|
+
* S4 (gh#11 N7, operator directive: team creation is a human act): the
|
|
195
|
+
* joinable-team curation list for a2a_team_join. ONLY an EXACT entry
|
|
196
|
+
* authorizes a join; trailing-`*` pattern entries stay readable here
|
|
197
|
+
* (whoami shows them) but carry no mint rights — a pattern that
|
|
198
|
+
* pre-authorized every future name under it would let a model mint teams
|
|
199
|
+
* nobody created. Empty list denies every join (default).
|
|
196
200
|
*/
|
|
197
201
|
readonly teamJoinAllowlist: string[];
|
|
198
|
-
/**
|
|
202
|
+
/**
|
|
203
|
+
* S3 (phase-2 default-ON, owner ruling 2026-09-01): route dispatch checks
|
|
204
|
+
* caller↔target shared team membership before addressing; set false to
|
|
205
|
+
* restore the open outbound seam. (Kept in sync with the schema comment.)
|
|
206
|
+
*/
|
|
199
207
|
readonly teamScopeRouting: boolean;
|
|
200
208
|
/**
|
|
201
209
|
* S5 (owner directive 2026-10-20): information-plane scoping — the
|