@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
|
@@ -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
|
}
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* `<team>/canvas/<name>` resolves the first live local member, wakes the
|
|
8
8
|
* first cold local one, then falls through to the remote members in order
|
|
9
9
|
* (the directory resolves their handles; remote nodes never wake from
|
|
10
|
-
* here).
|
|
10
|
+
* here). The dispatching session itself is exempt from the local tiers
|
|
11
|
+
* (issue #41: the self-route rule extends to canvas — an initiator
|
|
12
|
+
* occupying a member slot never captures its own dispatch, which would
|
|
13
|
+
* only echo home; the seam lives in the dispatch layer, the store stays
|
|
14
|
+
* pure). Membership order is the routing priority (member list order),
|
|
11
15
|
* preserved verbatim on disk. Plain JSON, one writer (the control routes),
|
|
12
16
|
* read fresh by the state route.
|
|
13
17
|
* @module @nelsonlongxiang/dsh-open-a2a-net/canvas-store
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/** Owner ruling ③: the default AND maximum online window. */
|
|
2
2
|
export declare const CLI_DEPOSIT_TTL_MS: number;
|
|
3
|
+
/**
|
|
4
|
+
* gh#47 tombstone bound: expired deposits stay visible as OFFLINE rows
|
|
5
|
+
* (newest TOMBSTONE_CAP keep their handle/name so team refs never dangle
|
|
6
|
+
* silently) — the owner manages CLI nodes like session nodes.
|
|
7
|
+
*/
|
|
8
|
+
export declare const CLI_TOMBSTONE_CAP = 16;
|
|
3
9
|
/** Owner ruling ①: <agent-name>/<uuid·8B>. The name is [a-z0-9-]{1,32}. */
|
|
4
10
|
export declare const CLI_HANDLE_RE: RegExp;
|
|
5
11
|
/** One deposited CLI identity. */
|
|
@@ -11,6 +17,12 @@ export interface CliDeposit {
|
|
|
11
17
|
readonly cardJson: string;
|
|
12
18
|
readonly depositedAt: number;
|
|
13
19
|
readonly expiresAt: number;
|
|
20
|
+
/**
|
|
21
|
+
* gh#47 tombstone: set when the deposit expired — the row stays (offline)
|
|
22
|
+
* so the directory/board keeps the member visible and team refs never
|
|
23
|
+
* dangle silently. A re-publish (same handle, same key) un-tombstones.
|
|
24
|
+
*/
|
|
25
|
+
expiredAt?: number;
|
|
14
26
|
}
|
|
15
27
|
/** One mailbox entry for a deposited handle. */
|
|
16
28
|
export interface CliMail {
|
|
@@ -37,17 +49,34 @@ export declare class CliDepositStore {
|
|
|
37
49
|
};
|
|
38
50
|
revoke(handle: string): boolean;
|
|
39
51
|
/** Active deposits (sweeping expired ones — ruling ③ auto-deregister). */
|
|
52
|
+
/** LIVE deposits only (tombstones excluded — the control list is the
|
|
53
|
+
* online roster; offline rows ride directoryRows for the board). */
|
|
40
54
|
list(): readonly CliDeposit[];
|
|
41
55
|
get(handle: string): CliDeposit | undefined;
|
|
42
|
-
/** Card-serve rows:
|
|
56
|
+
/** Card-serve rows: LIVE deposits only (an expired card 404s). */
|
|
43
57
|
cardRows(): Array<{
|
|
44
58
|
team: string;
|
|
45
59
|
name: string;
|
|
46
60
|
description: string;
|
|
47
61
|
}>;
|
|
62
|
+
/**
|
|
63
|
+
* gh#47 directory rows: LIVE + TOMBSTONE (offline) — the board renders
|
|
64
|
+
* offline member cards grayed but PRESENT, so a canvas ref to an expired
|
|
65
|
+
* CLI never dangles silently. Re-publish un-tombstones (same handle).
|
|
66
|
+
*/
|
|
67
|
+
directoryRows(): Array<{
|
|
68
|
+
team: string;
|
|
69
|
+
name: string;
|
|
70
|
+
description: string;
|
|
71
|
+
offline: boolean;
|
|
72
|
+
}>;
|
|
48
73
|
mail(handle: string, from: string, text: string): void;
|
|
49
74
|
readMail(handle: string): readonly CliMail[];
|
|
50
75
|
private sweep;
|
|
76
|
+
/** Mark one row expired (tombstone) and persist. */
|
|
77
|
+
private markExpired;
|
|
78
|
+
/** Keep at most TOMBSTONE_CAP offline rows — oldest evictions fall off. */
|
|
79
|
+
private pruneTombstones;
|
|
51
80
|
private persist;
|
|
52
81
|
private restore;
|
|
53
82
|
}
|
|
@@ -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
|
|
@@ -18,6 +18,22 @@ export declare const FAILURE_PENALTY = 100;
|
|
|
18
18
|
export declare const SUCCESS_REWARD = 100;
|
|
19
19
|
/** Score floor: at or below this a non-seed peer is evicted. */
|
|
20
20
|
export declare const EVICTION_FLOOR = 0;
|
|
21
|
+
/**
|
|
22
|
+
* Eviction cooldown (the zombie-referral breaker): an evicted dead address
|
|
23
|
+
* is refused by {@link PeerStore.offer} for this long — without it the
|
|
24
|
+
* address comes straight back through a linked peer's card advertising
|
|
25
|
+
* (evict → learn → fail → evict forever; observed grinding .156:3081 down
|
|
26
|
+
* 3400 points). v1 keeps the window FIXED at 24h (dead-letter TTL
|
|
27
|
+
* convention); progressive backoff is a future note, not this slice.
|
|
28
|
+
*/
|
|
29
|
+
export declare const EVICTION_COOLDOWN_MS: number;
|
|
30
|
+
/** Ledger bound: the newest 64 evictions survive (LRU by evictedAt). */
|
|
31
|
+
export declare const EVICTION_LEDGER_CAP = 64;
|
|
32
|
+
/** One eviction-ledger row: when a URL was evicted (epoch ms). */
|
|
33
|
+
export interface EvictionRecord {
|
|
34
|
+
readonly url: string;
|
|
35
|
+
readonly evictedAt: number;
|
|
36
|
+
}
|
|
21
37
|
/** One tracked peer. */
|
|
22
38
|
export interface PeerRecord {
|
|
23
39
|
readonly url: string;
|
|
@@ -36,6 +52,8 @@ export interface PeerRecord {
|
|
|
36
52
|
/** The persisted store document (whole-file read/write, small by design). */
|
|
37
53
|
export interface PeerStoreSnapshot {
|
|
38
54
|
readonly peers: readonly PeerRecord[];
|
|
55
|
+
/** Eviction ledger (gh#46): absent on pre-cooldown files = empty. */
|
|
56
|
+
readonly evictions?: readonly EvictionRecord[];
|
|
39
57
|
}
|
|
40
58
|
/**
|
|
41
59
|
* Bounded, quality-scored peer collection with whole-file persistence.
|
|
@@ -48,12 +66,15 @@ export declare class PeerStore {
|
|
|
48
66
|
private readonly cap;
|
|
49
67
|
private readonly persistDebounceMs;
|
|
50
68
|
private readonly peers;
|
|
69
|
+
/** Eviction ledger (gh#46): url → when it was evicted. Seeds never enter. */
|
|
70
|
+
private readonly evictions;
|
|
71
|
+
private readonly now;
|
|
51
72
|
/**
|
|
52
73
|
* @param seeds - the configured seed URLs; always retained (never evicted).
|
|
53
74
|
* @param path - persistence file (`<dsh-home>/a2a/peers.json`); empty = no persistence.
|
|
54
75
|
* @param cap - peer bound, seeds included (default {@link PEER_CAP}).
|
|
55
76
|
*/
|
|
56
|
-
constructor(seeds: readonly string[], path: string, cap?: number, persistDebounceMs?: number);
|
|
77
|
+
constructor(seeds: readonly string[], path: string, cap?: number, persistDebounceMs?: number, now?: () => number);
|
|
57
78
|
/**
|
|
58
79
|
* Every tracked peer URL: seeds first (in first-tracked order), then
|
|
59
80
|
* offered peers by score descending (equal scores keep first-tracked order).
|
|
@@ -131,6 +152,8 @@ export declare class PeerStore {
|
|
|
131
152
|
bySession(session: string): string | undefined;
|
|
132
153
|
/** Load a persisted snapshot on construction, if present. */
|
|
133
154
|
private restore;
|
|
155
|
+
/** Keep the ledger at its LRU bound: the oldest evictions fall off. */
|
|
156
|
+
private pruneLedger;
|
|
134
157
|
/**
|
|
135
158
|
* Persist the current peer set (no-op when no path was configured).
|
|
136
159
|
* Writes are debounced and asynchronous: a sweep that settles dozens of
|
|
@@ -0,0 +1,56 @@
|
|
|
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 declare const DEFAULT_HOP_BUDGET = 4;
|
|
17
|
+
/** Wire code for a zero-budget arrival (cluster-C registry, after -32006). */
|
|
18
|
+
export declare const WIRE_ERROR_BUDGET_EXHAUSTED = -32007;
|
|
19
|
+
/** The guard payload as it travels on the wire. */
|
|
20
|
+
export interface RecursionGuard {
|
|
21
|
+
readonly visitToken: string;
|
|
22
|
+
readonly hopBudget: number;
|
|
23
|
+
readonly deadlineAt?: number;
|
|
24
|
+
}
|
|
25
|
+
export type GuardVerdict = {
|
|
26
|
+
readonly kind: 'ok';
|
|
27
|
+
readonly guard: RecursionGuard;
|
|
28
|
+
readonly mode: 'explicit' | 'default';
|
|
29
|
+
} | {
|
|
30
|
+
readonly kind: 'budget-exhausted';
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Ingress check: parse the header, validate the budget, fail closed. A
|
|
34
|
+
* malformed payload falls back to the default budget (a guard that cannot
|
|
35
|
+
* be evaluated must not reject honest first-hop traffic; the budget bound
|
|
36
|
+
* still applies). hopBudget <= 0 on arrival ⇒ budget-exhausted.
|
|
37
|
+
*/
|
|
38
|
+
export declare function checkGuardAtIngress(header: string | undefined): GuardVerdict;
|
|
39
|
+
/** Mint the outbound guard for a first-hop dispatch (full budget at origin). */
|
|
40
|
+
export declare function mintGuard(): RecursionGuard;
|
|
41
|
+
/** Serialize for the X-A2A-Guard header. */
|
|
42
|
+
export declare function guardHeader(guard: RecursionGuard): string;
|
|
43
|
+
/**
|
|
44
|
+
* The ingress refusal path (gh#60): budget-exhausted answers the structured
|
|
45
|
+
* error, records the receiptless ledger row, and reports true (the caller
|
|
46
|
+
* returns). The whole response lives here so the ingress seam stays ONE
|
|
47
|
+
* line (the index.ts ratchet pays-as-it-goes: no net line growth).
|
|
48
|
+
*/
|
|
49
|
+
export declare function refuseIfBudgetExhausted(req: {
|
|
50
|
+
headers: Record<string, unknown>;
|
|
51
|
+
}, res: {
|
|
52
|
+
writeHead: (status: number, headers: Record<string, string | number>) => void;
|
|
53
|
+
end: (body: string) => void;
|
|
54
|
+
}, team: string, caller: string, taskId: string, ledger: {
|
|
55
|
+
trackReceiptless: (taskId: string, team: string, peer: string, contextId?: string, brief?: string) => void;
|
|
56
|
+
}): boolean;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared A2A runtime types (gh#77): shapes that used to be declared inside
|
|
3
|
+
* `apply()` and are now read by the face modules as well.
|
|
4
|
+
* @module src/runtime
|
|
5
|
+
*/
|
|
6
|
+
import type { A2aPeerCard } from './types.ts';
|
|
7
|
+
/**
|
|
8
|
+
* Recent routing activity for the network panel: a bounded ring of the
|
|
9
|
+
* latest inbound and outbound route outcomes, newest last. The state route
|
|
10
|
+
* serves a copy; the panel derives unread badges from new entries.
|
|
11
|
+
*/
|
|
12
|
+
export interface RouteActivityEntry {
|
|
13
|
+
readonly ts: number;
|
|
14
|
+
readonly dir: 'in' | 'out';
|
|
15
|
+
readonly team: string;
|
|
16
|
+
readonly peer: string;
|
|
17
|
+
readonly ok: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* In-flight outbound routes: a route registers on dial and unregisters on
|
|
21
|
+
* settlement, so the panel can show collaboration in progress ("who is
|
|
22
|
+
* waiting on whom") — the state route serves a snapshot.
|
|
23
|
+
*/
|
|
24
|
+
export interface InFlightRoute {
|
|
25
|
+
readonly id: number;
|
|
26
|
+
readonly team: string;
|
|
27
|
+
readonly peer: string;
|
|
28
|
+
readonly startedAt: number;
|
|
29
|
+
}
|
|
30
|
+
/** One peer-card cache entry: when it was fetched and what came back. */
|
|
31
|
+
export interface CardCacheEntry {
|
|
32
|
+
at: number;
|
|
33
|
+
card: A2aPeerCard | undefined;
|
|
34
|
+
}
|
|
@@ -120,6 +120,12 @@ export interface ArchivedRecord {
|
|
|
120
120
|
readonly endedBy?: ArchiveEnd;
|
|
121
121
|
/** gh#31: which side of the wire the row served, copied from the owed row (absent on rows archived before the field). */
|
|
122
122
|
readonly direction?: 'inbound' | 'outbound';
|
|
123
|
+
/**
|
|
124
|
+
* gh#68: copied from the owed row when it awaited no receipt, so a tidied
|
|
125
|
+
* fire-forget expiry is not read as a send we gave up on. Only `false` is
|
|
126
|
+
* ever stored; absent means a receipt was owed (or the row predates the field).
|
|
127
|
+
*/
|
|
128
|
+
readonly receiptExpected?: false;
|
|
123
129
|
}
|
|
124
130
|
/**
|
|
125
131
|
* How one archived row ended (gh#31). The recorded field wins; legacy rows
|