@nelsonlongxiang/dsh-open-a2a-net 0.6.28 → 0.6.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -14
- package/README_zh.md +83 -7
- package/assets/nexusDist/assets/index-C_x3iAth.js +9 -0
- package/assets/nexusDist/assets/index-Dz8N80tI.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +0 -0
- package/cordis.patch.yml +21 -21
- package/lib/canvas-store.js +21 -1
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +755 -0
- package/lib/faces/canvas-notify.js +138 -0
- package/lib/faces/wire.js +669 -0
- package/lib/flood-guard.js +106 -0
- package/lib/index.js +200 -1392
- package/lib/recursion-guard.js +78 -0
- package/lib/runtime.js +1 -0
- package/lib/skill/SKILL.md +35 -9
- package/lib/skill/references/cli.md +24 -7
- package/lib/skill/references/collaboration.md +355 -0
- package/lib/skill/references/discovery.md +5 -2
- package/lib/skill/references/pitfalls.md +2 -1
- package/lib/skill/references/routing.md +18 -8
- package/lib/skill/references/setup.md +152 -0
- package/lib/task-ledger.js +40 -4
- package/lib/types/canvas-store.d.ts +10 -1
- package/lib/types/faces/admin.d.ts +133 -0
- package/lib/types/faces/canvas-notify.d.ts +74 -0
- package/lib/types/faces/wire.d.ts +171 -0
- package/lib/types/flood-guard.d.ts +29 -0
- package/lib/types/index.d.ts +11 -3
- package/lib/types/recursion-guard.d.ts +56 -0
- package/lib/types/runtime.d.ts +35 -0
- package/lib/types/task-ledger.d.ts +15 -1
- package/nexus-stage/package-lock.json +2262 -2262
- package/nexus-stage/src/canvas-ops.ts +175 -175
- package/nexus-stage/src/canvas-wire.ts +156 -156
- package/nexus-stage/src/edges.ts +72 -72
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +240 -240
- package/nexus-stage/src/layout-doc.ts +143 -143
- package/nexus-stage/src/layout-wire.ts +158 -158
- package/nexus-stage/src/main.ts +5 -1
- package/nexus-stage/src/planning-card-text.ts +73 -0
- package/nexus-stage/src/planning-edges.ts +26 -0
- package/nexus-stage/src/planning-fit.ts +45 -0
- package/nexus-stage/src/planning-legend.ts +38 -0
- package/nexus-stage/src/planning-mirror.ts +31 -0
- package/nexus-stage/src/planning-overlap.ts +159 -0
- package/nexus-stage/src/planning-relayout.ts +121 -0
- package/nexus-stage/src/planning-search.ts +168 -0
- package/nexus-stage/src/planning-types.ts +1 -0
- package/nexus-stage/src/planning-undo.ts +68 -0
- package/nexus-stage/src/planning-view.ts +120 -121
- package/nexus-stage/src/planning.css +41 -8
- package/nexus-stage/src/seat.ts +35 -35
- package/nexus-stage/src/stage-name.ts +13 -13
- package/nexus-stage/src/tokens.ts +31 -31
- package/nexus-stage/src/viewport.ts +24 -1
- package/nexus-stage/src/wb-chat.ts +20 -6
- package/nexus-stage/src/wb-ledger.ts +20 -7
- package/nexus-stage/src/wb-names.ts +43 -0
- package/nexus-stage/src/workbench.css +8 -2
- package/nexus-stage/src/workbench.ts +55 -17
- package/nexus-stage/src/world.ts +13 -21
- package/package.json +216 -214
- package/assets/nexusDist/assets/index-1niOBYxp.js +0 -5
- package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
- package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
|
@@ -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
|
@@ -60,7 +60,7 @@ export function briefOf(message) {
|
|
|
60
60
|
* lazy — every read/write entry point sweeps first.
|
|
61
61
|
*/
|
|
62
62
|
export const TASK_STALE_TTL_MS = 24 * 60 * 60_000;
|
|
63
|
-
const ARCHIVE_ENDS = ['receipt', 'echo', 'abandoned', 'cancelled'];
|
|
63
|
+
const ARCHIVE_ENDS = ['receipt', 'echo', 'abandoned', 'cancelled', 'evicted'];
|
|
64
64
|
/** Summary marker {@link TaskLedger.resolveEcho} writes on echo-settled rows. */
|
|
65
65
|
const ECHO_SUMMARY = '(settled by content echo)';
|
|
66
66
|
/**
|
|
@@ -165,7 +165,8 @@ export class TaskLedger {
|
|
|
165
165
|
...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
|
|
166
166
|
...(brief !== undefined && brief !== '' ? { brief } : {}),
|
|
167
167
|
status: 'pending',
|
|
168
|
-
}, ...this.tasks]
|
|
168
|
+
}, ...this.tasks];
|
|
169
|
+
this.trimOwed();
|
|
169
170
|
this.persist();
|
|
170
171
|
}
|
|
171
172
|
track(taskId, team, peer, contextId, brief) {
|
|
@@ -185,7 +186,8 @@ export class TaskLedger {
|
|
|
185
186
|
...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
|
|
186
187
|
...(brief !== undefined && brief !== '' ? { brief } : {}),
|
|
187
188
|
status: 'pending',
|
|
188
|
-
}, ...this.tasks]
|
|
189
|
+
}, ...this.tasks];
|
|
190
|
+
this.trimOwed();
|
|
189
191
|
this.persist();
|
|
190
192
|
}
|
|
191
193
|
/**
|
|
@@ -219,7 +221,8 @@ export class TaskLedger {
|
|
|
219
221
|
...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
|
|
220
222
|
...(brief !== undefined && brief !== '' ? { brief } : {}),
|
|
221
223
|
status: 'pending',
|
|
222
|
-
}, ...this.tasks]
|
|
224
|
+
}, ...this.tasks];
|
|
225
|
+
this.trimOwed();
|
|
223
226
|
this.persist();
|
|
224
227
|
}
|
|
225
228
|
/**
|
|
@@ -272,6 +275,7 @@ export class TaskLedger {
|
|
|
272
275
|
outcome: 'abandoned',
|
|
273
276
|
endedBy: 'abandoned',
|
|
274
277
|
...(record.direction !== undefined ? { direction: record.direction } : {}),
|
|
278
|
+
...(record.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
275
279
|
});
|
|
276
280
|
this.persist();
|
|
277
281
|
return { outcome: 'cleared', taskId, archivedAt: resolvedAt };
|
|
@@ -313,6 +317,7 @@ export class TaskLedger {
|
|
|
313
317
|
outcome: 'abandoned',
|
|
314
318
|
endedBy: 'cancelled',
|
|
315
319
|
...(record.direction !== undefined ? { direction: record.direction } : {}),
|
|
320
|
+
...(record.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
316
321
|
});
|
|
317
322
|
this.persist();
|
|
318
323
|
return {
|
|
@@ -363,6 +368,7 @@ export class TaskLedger {
|
|
|
363
368
|
...(elapsedMs !== undefined ? { elapsedMs } : {}),
|
|
364
369
|
endedBy: 'receipt',
|
|
365
370
|
...(record.direction !== undefined ? { direction: record.direction } : {}),
|
|
371
|
+
...(record.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
366
372
|
});
|
|
367
373
|
this.persist();
|
|
368
374
|
return {
|
|
@@ -445,6 +451,7 @@ export class TaskLedger {
|
|
|
445
451
|
summary: ECHO_SUMMARY,
|
|
446
452
|
endedBy: 'echo',
|
|
447
453
|
...(record.direction !== undefined ? { direction: record.direction } : {}),
|
|
454
|
+
...(record.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
448
455
|
});
|
|
449
456
|
settled.push(record.taskId);
|
|
450
457
|
}
|
|
@@ -456,6 +463,33 @@ export class TaskLedger {
|
|
|
456
463
|
archiveSettled(record) {
|
|
457
464
|
this.archived = [record, ...this.archived].slice(0, this.archiveCap);
|
|
458
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* gh#86 (chief order direct-4f596524): when the owed book exceeds
|
|
468
|
+
* TASK_CAP, the overflow rows go to the ARCHIVE as evicted - visible
|
|
469
|
+
* for audit, never silently dropped. The review seat's recommendation,
|
|
470
|
+
* chief-endorsed: the silent cap slice lost the oldest pending rows
|
|
471
|
+
* with no trace. L8 criterion: zero silently-dropped.
|
|
472
|
+
*/
|
|
473
|
+
trimOwed() {
|
|
474
|
+
if (this.tasks.length <= TASK_CAP)
|
|
475
|
+
return;
|
|
476
|
+
const overflow = this.tasks.slice(TASK_CAP);
|
|
477
|
+
this.tasks = this.tasks.slice(0, TASK_CAP);
|
|
478
|
+
const now = this.now();
|
|
479
|
+
for (const row of overflow) {
|
|
480
|
+
this.archiveSettled({
|
|
481
|
+
taskId: row.taskId,
|
|
482
|
+
team: row.team,
|
|
483
|
+
peer: row.peer,
|
|
484
|
+
startedAt: row.startedAt,
|
|
485
|
+
...(row.contextId !== undefined ? { contextId: row.contextId } : {}),
|
|
486
|
+
resolvedAt: now,
|
|
487
|
+
...(row.brief !== undefined ? { brief: row.brief } : {}),
|
|
488
|
+
endedBy: 'evicted',
|
|
489
|
+
...(row.direction !== undefined ? { direction: row.direction } : {}),
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
459
493
|
/** Lazy dead-letter sweep: overdue pending rows flip status, at most once. */
|
|
460
494
|
sweep(now) {
|
|
461
495
|
let changed = false;
|
|
@@ -505,6 +539,7 @@ export class TaskLedger {
|
|
|
505
539
|
...(typeof entry.lateReceiptAt === 'number' && Number.isFinite(entry.lateReceiptAt) ? { lateReceiptAt: entry.lateReceiptAt } : {}),
|
|
506
540
|
...(typeof entry.endedBy === 'string' && ARCHIVE_ENDS.includes(entry.endedBy) ? { endedBy: entry.endedBy } : {}),
|
|
507
541
|
...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
|
|
542
|
+
...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
508
543
|
});
|
|
509
544
|
continue;
|
|
510
545
|
}
|
|
@@ -550,6 +585,7 @@ export class TaskLedger {
|
|
|
550
585
|
...(typeof entry.lateReceiptAt === 'number' && Number.isFinite(entry.lateReceiptAt) ? { lateReceiptAt: entry.lateReceiptAt } : {}),
|
|
551
586
|
...(typeof entry.endedBy === 'string' && ARCHIVE_ENDS.includes(entry.endedBy) ? { endedBy: entry.endedBy } : {}),
|
|
552
587
|
...(entry.direction === 'inbound' || entry.direction === 'outbound' ? { direction: entry.direction } : {}),
|
|
588
|
+
...(entry.receiptExpected === false ? { receiptExpected: false } : {}),
|
|
553
589
|
});
|
|
554
590
|
}
|
|
555
591
|
}
|
|
@@ -51,14 +51,23 @@ export declare class CanvasStore {
|
|
|
51
51
|
/** Team entries in creation order; members in add order (routing priority). */
|
|
52
52
|
private teams;
|
|
53
53
|
private readonly path;
|
|
54
|
+
private readonly listeners;
|
|
54
55
|
/**
|
|
55
56
|
* @param path - the snapshot file; an empty path keeps the store in memory.
|
|
56
57
|
*/
|
|
57
58
|
constructor(path: string);
|
|
59
|
+
/**
|
|
60
|
+
* Observe changes: the listener runs after every write that changed the
|
|
61
|
+
* store (never on restore). A throwing listener cannot break the write.
|
|
62
|
+
* @param listener - called with no arguments.
|
|
63
|
+
* @returns the unsubscribe function.
|
|
64
|
+
*/
|
|
65
|
+
onChange(listener: () => void): () => void;
|
|
58
66
|
/** Load a persisted snapshot, if present and well-formed. */
|
|
59
67
|
private restore;
|
|
60
|
-
/** Persist the current snapshot (no-op without a path). */
|
|
68
|
+
/** Persist the current snapshot (no-op without a path), then tell the listeners. */
|
|
61
69
|
private persist;
|
|
70
|
+
private write;
|
|
62
71
|
/** Every team name, in creation order. */
|
|
63
72
|
list(): readonly string[];
|
|
64
73
|
/** Whether the named team exists. */
|
|
@@ -0,0 +1,133 @@
|
|
|
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 CanvasNotifyDeps } from './canvas-notify.ts';
|
|
9
|
+
import type { CliDepositStore } from '../cli-deposit.ts';
|
|
10
|
+
import type { ExposureStore } from '../exposure-store.ts';
|
|
11
|
+
import type { GroupStore } from '../group-store.ts';
|
|
12
|
+
import type { IdempotencyStore } from '../idempotency-store.ts';
|
|
13
|
+
import type { JoinedSessions } from '../joined-store.ts';
|
|
14
|
+
import type { LayoutStore } from '../layout-store.ts';
|
|
15
|
+
import type { PeerStore } from '../peer-store.ts';
|
|
16
|
+
import { type TaskLedger } from '../task-ledger.ts';
|
|
17
|
+
import type { TeamMembershipStore } from '../team-store.ts';
|
|
18
|
+
/** Remote-team rows cached for the state face (see refreshRemoteRows). */
|
|
19
|
+
export type RemoteRowsCache = {
|
|
20
|
+
at: number;
|
|
21
|
+
rows: {
|
|
22
|
+
team: string;
|
|
23
|
+
name: string;
|
|
24
|
+
origin?: string;
|
|
25
|
+
via?: string;
|
|
26
|
+
workspace?: string;
|
|
27
|
+
legacy?: boolean;
|
|
28
|
+
teams?: readonly string[];
|
|
29
|
+
}[];
|
|
30
|
+
} | undefined;
|
|
31
|
+
/** Everything the admin faces used to capture from `apply()`. */
|
|
32
|
+
export interface AdminFaceDeps extends Pick<CanvasNotifyDeps, 'verifyOriginClaim' | 'callerLinkedToThisHost' | 'cachedCardFetch'> {
|
|
33
|
+
readonly ctx: Context;
|
|
34
|
+
readonly config: Config;
|
|
35
|
+
readonly logger: ReturnType<Context['logger']>;
|
|
36
|
+
readonly home: string;
|
|
37
|
+
readonly session: string;
|
|
38
|
+
readonly lanIp: string;
|
|
39
|
+
readonly PLUGIN_VERSION: string;
|
|
40
|
+
/** index.ts's `import.meta.url` — the base the asset roots resolve against. */
|
|
41
|
+
readonly indexUrl: string;
|
|
42
|
+
readonly controlRoute: (handler: (req: IncomingMessage, res: ServerResponse) => void) => (req: IncomingMessage, res: ServerResponse) => void;
|
|
43
|
+
readonly controlTrustedWithoutKey: (req: IncomingMessage) => boolean;
|
|
44
|
+
readonly readJsonBody: (req: IncomingMessage, res: ServerResponse, use: (body: {
|
|
45
|
+
readonly id?: unknown;
|
|
46
|
+
readonly name?: unknown;
|
|
47
|
+
readonly action?: unknown;
|
|
48
|
+
}) => void, maxBytes?: number) => void;
|
|
49
|
+
readonly peerStore: PeerStore;
|
|
50
|
+
readonly peerLinkState: (peerUrl: string) => {
|
|
51
|
+
link: 'linked' | 'stranger';
|
|
52
|
+
verified: boolean;
|
|
53
|
+
};
|
|
54
|
+
readonly originClaimHeaders: () => Record<string, string>;
|
|
55
|
+
readonly refreshRemoteRows: () => void;
|
|
56
|
+
readonly joinedSessions: JoinedSessions;
|
|
57
|
+
readonly exposureStore: ExposureStore;
|
|
58
|
+
readonly teamMemberships: TeamMembershipStore;
|
|
59
|
+
readonly cliDeposits: CliDepositStore;
|
|
60
|
+
readonly groupStore: GroupStore;
|
|
61
|
+
readonly canvasStore: CanvasStore;
|
|
62
|
+
readonly layoutStore: LayoutStore;
|
|
63
|
+
readonly taskLedger: TaskLedger;
|
|
64
|
+
readonly idempotencyStore: IdempotencyStore;
|
|
65
|
+
readonly inFlightRoutes: Map<number, InFlightRoute>;
|
|
66
|
+
readonly recentActivity: RouteActivityEntry[];
|
|
67
|
+
readonly teamAuditRing: {
|
|
68
|
+
at: number;
|
|
69
|
+
action: 'join' | 'leave' | 'archive-prune' | 'agent-less';
|
|
70
|
+
team: string;
|
|
71
|
+
id: string;
|
|
72
|
+
ok: boolean;
|
|
73
|
+
reason?: string;
|
|
74
|
+
}[];
|
|
75
|
+
readonly reportRing: {
|
|
76
|
+
at: number;
|
|
77
|
+
level: 'info' | 'warning' | 'error';
|
|
78
|
+
message: string;
|
|
79
|
+
session: string;
|
|
80
|
+
task?: string;
|
|
81
|
+
}[];
|
|
82
|
+
readonly sessionNodes: Map<string, Agent>;
|
|
83
|
+
readonly liveRoots: Map<string, Agent>;
|
|
84
|
+
readonly mountSessionNode: (agent: Agent) => void;
|
|
85
|
+
readonly coldJoinedIds: () => Promise<Set<string>>;
|
|
86
|
+
readonly prewarmStatus: {
|
|
87
|
+
state: 'off' | 'skipped:no-wake-face' | 'draining' | 'done' | 'cancelled';
|
|
88
|
+
attempted: number;
|
|
89
|
+
woken: number;
|
|
90
|
+
failed: Array<{
|
|
91
|
+
id: string;
|
|
92
|
+
error: string;
|
|
93
|
+
}>;
|
|
94
|
+
};
|
|
95
|
+
readonly reconcileStatus: {
|
|
96
|
+
state: 'off' | 'idle' | 'draining';
|
|
97
|
+
lastTickAt: number;
|
|
98
|
+
lastChecked: number;
|
|
99
|
+
woken: number;
|
|
100
|
+
rows: Array<{
|
|
101
|
+
id: string;
|
|
102
|
+
error: string;
|
|
103
|
+
attempts: number;
|
|
104
|
+
nextRetryAt: number;
|
|
105
|
+
needsRepair: boolean;
|
|
106
|
+
}>;
|
|
107
|
+
};
|
|
108
|
+
readonly id8: (id: string) => string;
|
|
109
|
+
readonly sessionLabelOf: (agent: Agent) => string;
|
|
110
|
+
readonly sessionTeamOf: (agent: Agent) => string;
|
|
111
|
+
readonly nodeMetadataOf: (agent: Agent) => {
|
|
112
|
+
name: string;
|
|
113
|
+
description: string;
|
|
114
|
+
workspace?: string;
|
|
115
|
+
};
|
|
116
|
+
readonly canvasTeamOf: (name: string) => string;
|
|
117
|
+
readonly archivedSessionFilter: () => ((id: string) => boolean) | undefined;
|
|
118
|
+
readonly pruneArchivedJoins: () => void;
|
|
119
|
+
readonly pruneCanvasMemberships: () => void;
|
|
120
|
+
readonly cardCache: Map<string, CardCacheEntry>;
|
|
121
|
+
readonly remoteRowsCache: RemoteRowsCache;
|
|
122
|
+
readonly legacyInboundCount: number;
|
|
123
|
+
readonly ownCardUrl: string;
|
|
124
|
+
ownerAllowlist: string[];
|
|
125
|
+
ownerTeamScopeInfo: boolean;
|
|
126
|
+
ownerAnnounceRestartPending: boolean;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Register the admin faces on a settled web server.
|
|
130
|
+
* @param webServer - the settled host web server.
|
|
131
|
+
* @param d - the captures listed on {@link AdminFaceDeps}.
|
|
132
|
+
*/
|
|
133
|
+
export declare function registerAdminFaces(webServer: WebServer, d: AdminFaceDeps): void;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas change notification (owner ruling 2026-10-29: single owner + mirror).
|
|
3
|
+
*
|
|
4
|
+
* A canvas team belongs to the host that created it. Peers MIRROR it from the
|
|
5
|
+
* owner's full card (`canvasOrg`, rendered through the state face's
|
|
6
|
+
* `peerOrg`) and EDIT it through the fleet relay (`/__dsh_a2a/fleet-canvas-op`
|
|
7
|
+
* → the owner's `/a2a/canvas-op`). Nothing is replicated, so there is no
|
|
8
|
+
* merge: the owner's store is the single truth. What was missing is
|
|
9
|
+
* freshness — a mirror only moved when the 60s card-cache window lapsed.
|
|
10
|
+
*
|
|
11
|
+
* This face closes that gap with a content-free ping:
|
|
12
|
+
* - SEND: any change to this host's canvas store (local board, a peer's
|
|
13
|
+
* canvas-op, a leave/archive prune) schedules one debounced
|
|
14
|
+
* `POST /a2a/canvas-changed` to every tracked peer that is linked to this
|
|
15
|
+
* host by the same predicate the card face uses to serve the full card —
|
|
16
|
+
* exactly the peers that can see `canvasOrg` at all. The body is empty:
|
|
17
|
+
* the ping says "re-read my card", never what changed.
|
|
18
|
+
* - RECEIVE: a verified OriginClaim from a TRACKED peer marks that peer's
|
|
19
|
+
* cached card stale (the card stays readable — no frame flicker) and
|
|
20
|
+
* refetches it through the shared single-flight cache; the board's next
|
|
21
|
+
* 3s poll renders the new org. Per-peer rate limit; a caller whose claimed
|
|
22
|
+
* URL is not exactly a tracked peer with a cached card is refused before
|
|
23
|
+
* its claim is verified (nothing is fetched on its say-so), an unsigned one
|
|
24
|
+
* right after.
|
|
25
|
+
* @module src/faces/canvas-notify
|
|
26
|
+
*/
|
|
27
|
+
import type { IncomingMessage } from 'node:http';
|
|
28
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
29
|
+
import type WebServer from '@deepseek-ai/dsh-host-webserver';
|
|
30
|
+
import type { A2aPeerCard } from '../types.ts';
|
|
31
|
+
import type { CanvasStore } from '../canvas-store.ts';
|
|
32
|
+
import type { PeerStore } from '../peer-store.ts';
|
|
33
|
+
import type { CardCacheEntry } from '../runtime.ts';
|
|
34
|
+
/** Coalesce a burst of store writes (a drag that adds three members) into one ping. */
|
|
35
|
+
export declare const CANVAS_NOTIFY_DEBOUNCE_MS = 400;
|
|
36
|
+
/** A peer's pings refresh its card at most this often. */
|
|
37
|
+
export declare const CANVAS_REFRESH_MIN_GAP_MS = 1000;
|
|
38
|
+
/** The captures this face needs from `apply()`. */
|
|
39
|
+
export interface CanvasNotifyDeps {
|
|
40
|
+
readonly ctx: Context;
|
|
41
|
+
readonly logger: ReturnType<Context['logger']>;
|
|
42
|
+
readonly peerStore: PeerStore;
|
|
43
|
+
readonly canvasStore: CanvasStore;
|
|
44
|
+
readonly cardCache: Map<string, CardCacheEntry>;
|
|
45
|
+
readonly originClaimHeaders: () => Record<string, string>;
|
|
46
|
+
readonly verifyOriginClaim: (req: IncomingMessage) => Promise<{
|
|
47
|
+
ok: true;
|
|
48
|
+
caller: string;
|
|
49
|
+
callerUrl: string;
|
|
50
|
+
card: A2aPeerCard;
|
|
51
|
+
} | {
|
|
52
|
+
ok: false;
|
|
53
|
+
reason: string;
|
|
54
|
+
}>;
|
|
55
|
+
readonly callerLinkedToThisHost: (caller: string, callerUrl: string, callerCard: A2aPeerCard | undefined) => boolean;
|
|
56
|
+
readonly cachedCardFetch: (url: string) => Promise<A2aPeerCard | undefined>;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Re-read one peer's card now. Each pass pre-stales the entry (the literal
|
|
60
|
+
* carries no missAt) so it is a GENUINE refetch: pass 1 may join a flight
|
|
61
|
+
* that started before the peer's change; pass 2 always starts after the
|
|
62
|
+
* first settled — that one is the answer. A miss keeps the last good card
|
|
63
|
+
* inside cachedCardFetch itself (the missAt window); there is nothing to
|
|
64
|
+
* restore here.
|
|
65
|
+
* @param d - the card cache and its single-flight fetch.
|
|
66
|
+
* @param url - the tracked peer URL.
|
|
67
|
+
*/
|
|
68
|
+
export declare function refreshPeerCard(d: Pick<CanvasNotifyDeps, 'cardCache' | 'cachedCardFetch'>, url: string): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Register the receive face and subscribe the send side to the canvas store.
|
|
71
|
+
* @param webServer - the settled host web server.
|
|
72
|
+
* @param d - the captures listed on {@link CanvasNotifyDeps}.
|
|
73
|
+
*/
|
|
74
|
+
export declare function registerCanvasNotify(webServer: WebServer, d: CanvasNotifyDeps): void;
|