@nelsonlongxiang/dsh-open-a2a-net 0.5.14 → 0.6.24
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 +243 -121
- package/README_zh.md +144 -84
- package/assets/nexusDist/assets/index-034h1cNc.js +3 -0
- package/assets/nexusDist/assets/index-PG9EieGX.css +1 -0
- package/assets/nexusDist/index.html +26 -0
- package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -0
- package/assets/stageDist/assets/index-C-28k_o5.js +974 -0
- package/assets/stageDist/assets/interiors-CQGBjffT.png +0 -0
- package/assets/stageDist/assets/office-tileset-CHQT-uYL.png +0 -0
- package/assets/stageDist/index.html +13 -0
- package/lib/a2a-client.js +190 -13
- package/lib/canvas-store.js +218 -0
- package/lib/card.js +25 -0
- package/lib/client/A2aControl.js +45 -137
- package/lib/client/locales.js +16 -40
- package/lib/client.js +164 -467
- package/lib/client.js.map +1 -1
- package/lib/group-store.js +6 -0
- package/lib/idempotency-store.js +291 -0
- package/lib/index.js +4217 -259
- package/lib/joined-store.js +11 -0
- package/lib/layout-store.js +155 -0
- package/lib/peer-store.js +98 -13
- package/lib/receipt-ladder.js +35 -0
- package/lib/receipt.js +102 -0
- package/lib/self-suppress.js +62 -0
- package/lib/session-transcript.js +150 -0
- package/lib/skill/SKILL.md +77 -0
- package/lib/skill/references/discovery.md +38 -0
- package/lib/skill/references/membership.md +50 -0
- package/lib/skill/references/pitfalls.md +42 -0
- package/lib/skill/references/routing.md +43 -0
- package/lib/skill-registration.js +52 -0
- package/lib/stage-mount.js +32 -0
- package/lib/task-ledger.js +540 -0
- package/lib/team-store.js +122 -0
- package/lib/teams-bridge.js +18 -0
- package/lib/transport-caps.js +31 -0
- package/lib/types/a2a-client.d.ts +72 -1
- package/lib/types/canvas-store.d.ts +102 -0
- package/lib/types/client/A2aControl.d.ts +18 -28
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/group-store.d.ts +6 -0
- package/lib/types/idempotency-store.d.ts +164 -0
- package/lib/types/index.d.ts +210 -6
- package/lib/types/joined-store.d.ts +9 -0
- package/lib/types/layout-store.d.ts +63 -0
- package/lib/types/peer-store.d.ts +45 -2
- package/lib/types/receipt-ladder.d.ts +25 -0
- package/lib/types/receipt.d.ts +51 -0
- package/lib/types/self-suppress.d.ts +41 -0
- package/lib/types/session-transcript.d.ts +37 -0
- package/lib/types/skill-registration.d.ts +3 -0
- package/lib/types/stage-mount.d.ts +29 -0
- package/lib/types/task-ledger.d.ts +308 -0
- package/lib/types/team-store.d.ts +55 -0
- package/lib/types/teams-bridge.d.ts +112 -0
- package/lib/types/transport-caps.d.ts +29 -0
- package/lib/types/types.d.ts +43 -0
- package/nexus-stage/package-lock.json +2262 -0
- package/nexus-stage/package.json +21 -0
- package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
- package/nexus-stage/scripts/mockhost.py +253 -0
- package/nexus-stage/src/canvas-ops.ts +175 -0
- package/nexus-stage/src/canvas-wire.ts +156 -0
- package/nexus-stage/src/edges.ts +72 -0
- package/nexus-stage/src/fault.ts +56 -0
- package/nexus-stage/src/federation.ts +234 -0
- package/nexus-stage/src/layout-doc.ts +143 -0
- package/nexus-stage/src/layout-wire.ts +158 -0
- package/nexus-stage/src/main.ts +361 -0
- package/nexus-stage/src/planning-menus.ts +352 -0
- package/nexus-stage/src/planning-nodelist.ts +294 -0
- package/nexus-stage/src/planning-types.ts +111 -0
- package/nexus-stage/src/planning-view.ts +1313 -0
- package/nexus-stage/src/planning.css +280 -0
- package/nexus-stage/src/seat.ts +35 -0
- package/nexus-stage/src/stage-name.ts +13 -0
- package/nexus-stage/src/tokens.ts +32 -0
- package/nexus-stage/src/viewport.ts +83 -0
- package/nexus-stage/src/workbench.css +267 -0
- package/nexus-stage/src/workbench.ts +1530 -0
- package/nexus-stage/src/world.ts +509 -0
- package/nexus-stage/tsconfig.json +25 -0
- package/nexus-stage/vite.config.ts +16 -0
- package/package.json +83 -22
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-a2a-nexus-stage",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc --noEmit && vite build",
|
|
8
|
+
"dev": "vite",
|
|
9
|
+
"test": "vitest run"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"typescript": "^5.6.3",
|
|
14
|
+
"vite": "^5.4.8",
|
|
15
|
+
"vitest": "^2.1.8",
|
|
16
|
+
"jsdom": "^24.1.0"
|
|
17
|
+
},
|
|
18
|
+
"allowScripts": {
|
|
19
|
+
"esbuild@0.21.5": true
|
|
20
|
+
}
|
|
21
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Standalone mock host for the nexus stage - the standing smoke artifact.
|
|
2
|
+
|
|
3
|
+
Serves the built stage (assets/nexusDist) plus canned /__dsh_a2a endpoints so
|
|
4
|
+
both proof scenes are plain openable URLs, no browser automation required:
|
|
5
|
+
|
|
6
|
+
http://127.0.0.1:<port>/ healthy (canned live-shaped state)
|
|
7
|
+
http://127.0.0.1:<port>/?fault=500 state answers 500 -> on-page fault badge
|
|
8
|
+
http://127.0.0.1:<port>/?demo=1 state merges a demo federation dataset
|
|
9
|
+
(2 peers + 2 inFlight routes) so the PR D
|
|
10
|
+
activity/federal layers stay demonstrable
|
|
11
|
+
offline and on fleets with no live routes
|
|
12
|
+
|
|
13
|
+
Usage: python scripts/mockhost.py [port] (default 8780)
|
|
14
|
+
State source: LIVE_STATE env, else http://127.0.0.1:3080/__dsh_a2a/state, with
|
|
15
|
+
a graceful empty-fleet fallback so offline dev still boots.
|
|
16
|
+
"""
|
|
17
|
+
import functools
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
import urllib.request
|
|
23
|
+
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8780
|
|
27
|
+
DIST = Path(__file__).resolve().parents[2] / 'assets' / 'nexusDist'
|
|
28
|
+
LIVE = os.environ.get('LIVE_STATE', 'http://127.0.0.1:3080/__dsh_a2a/state')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fetch_state() -> bytes:
|
|
32
|
+
try:
|
|
33
|
+
return urllib.request.urlopen(LIVE, timeout=5).read()
|
|
34
|
+
except Exception: # noqa: BLE001 - offline dev must still boot
|
|
35
|
+
return json.dumps({'sessions': [], 'canvas': {'teams': []}, 'peers': []}).encode()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
state_bytes = fetch_state()
|
|
39
|
+
# The layout store starts absent and remembers saves in memory only - the
|
|
40
|
+
# mock exists to exercise the stage's save loop (lamp ladder), not to be a
|
|
41
|
+
# second implementation of the host's clamp (the stage normalizes on GET).
|
|
42
|
+
# Optional on-disk persistence: set LAYOUT_FILE=<path> so the layout
|
|
43
|
+
# survives mock-host restarts (the in-memory default wipes on exit).
|
|
44
|
+
# Corrupt files are treated as absent - presentation state only.
|
|
45
|
+
LAYOUT_FILE = os.environ.get('LAYOUT_FILE', '')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def layout_load():
|
|
49
|
+
if not LAYOUT_FILE or not os.path.exists(LAYOUT_FILE):
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
with open(LAYOUT_FILE, 'r', encoding='utf-8') as f:
|
|
53
|
+
return json.load(f)
|
|
54
|
+
except Exception: # noqa: BLE001 - corrupt layout is presentation loss only
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def layout_persist(doc):
|
|
59
|
+
if not LAYOUT_FILE:
|
|
60
|
+
return
|
|
61
|
+
try:
|
|
62
|
+
fd = os.open(LAYOUT_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
63
|
+
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
|
64
|
+
f.write('' if doc is None else json.dumps(doc))
|
|
65
|
+
except OSError:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
layout_store = {'doc': None}
|
|
70
|
+
|
|
71
|
+
# ── canvas store (PR C): mutable teams, mirrored shapes from src/canvas-store.ts,
|
|
72
|
+
# so team writes are visible on the NEXT POLL (state is regenerated per request
|
|
73
|
+
# from this store - a startup snapshot would hide every write). ──
|
|
74
|
+
CANVAS_NAME_MAX = 40
|
|
75
|
+
CANVAS_TEAM_CAP = 64
|
|
76
|
+
CANVAS_MEMBER_CAP = 32
|
|
77
|
+
|
|
78
|
+
_BASE = json.loads(state_bytes)
|
|
79
|
+
member_rows = {} # id -> rich member row captured from the snapshot
|
|
80
|
+
joined_ids = {s.get('id') for s in _BASE.get('sessions', []) if isinstance(s, dict)}
|
|
81
|
+
teams = {} # name -> [member ids], order = routing priority
|
|
82
|
+
host_prefix = 'dsh'
|
|
83
|
+
for _t in _BASE.get('canvas', {}).get('teams', []):
|
|
84
|
+
teams[_t['name']] = [m['id'] for m in _t.get('members', [])]
|
|
85
|
+
for _m in _t.get('members', []):
|
|
86
|
+
member_rows[_m['id']] = _m
|
|
87
|
+
_p = _m.get('team', '').split('/')[0]
|
|
88
|
+
if _p:
|
|
89
|
+
host_prefix = _p
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def valid_name(raw):
|
|
94
|
+
"""Mirror of canvas-store.validName: trim, 1..40, no '/', not pure digits."""
|
|
95
|
+
if not isinstance(raw, str):
|
|
96
|
+
return None
|
|
97
|
+
name = raw.strip()
|
|
98
|
+
if name == '' or len(name) > CANVAS_NAME_MAX or '/' in name or name.isdigit():
|
|
99
|
+
return None
|
|
100
|
+
return name
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── demo federation dataset (PR D): merged into /__dsh_a2a/state only when the
|
|
104
|
+
# request carries demo=1 (query or Referer), so the activity/federal layers are
|
|
105
|
+
# demonstrable offline and on live fleets that have no in-flight routes. ──
|
|
106
|
+
DEMO_PEERS = [
|
|
107
|
+
{'url': 'http://192.168.3.88:3080', 'score': 42},
|
|
108
|
+
{'url': 'http://10.0.0.7:13080', 'score': 17},
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_state(demo: bool = False) -> bytes:
|
|
113
|
+
"""The snapshot with canvas.teams regenerated from the mutable store."""
|
|
114
|
+
state = dict(_BASE)
|
|
115
|
+
team_rows = []
|
|
116
|
+
for name, ids in teams.items():
|
|
117
|
+
members = []
|
|
118
|
+
for mid in ids:
|
|
119
|
+
row = member_rows.get(mid)
|
|
120
|
+
if row is None:
|
|
121
|
+
row = {'id': mid, 'team': f'{host_prefix}/{mid[:8]}', 'joined': True, 'live': True}
|
|
122
|
+
member_rows[mid] = row
|
|
123
|
+
members.append(row)
|
|
124
|
+
team_rows.append({'name': name, 'team': f'{host_prefix}/canvas/{name}', 'members': members})
|
|
125
|
+
state['canvas'] = {'teams': team_rows}
|
|
126
|
+
if demo:
|
|
127
|
+
# fresh list copies - _BASE's own peers must stay untouched
|
|
128
|
+
state['peers'] = list(state.get('peers') or []) + [dict(p) for p in DEMO_PEERS]
|
|
129
|
+
names = [row['name'] for row in team_rows]
|
|
130
|
+
first = names[0] if names else 'dsh'
|
|
131
|
+
second = names[1] if len(names) > 1 else first
|
|
132
|
+
now_ms = int(time.time() * 1000)
|
|
133
|
+
state['inFlight'] = [
|
|
134
|
+
{'team': first, 'peer': '192.168.3.88:3080', 'startedAt': now_ms - 12000},
|
|
135
|
+
{'team': second, 'peer': '10.0.0.7:13080', 'startedAt': now_ms - 45000},
|
|
136
|
+
]
|
|
137
|
+
return json.dumps(state).encode()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class MockHost(SimpleHTTPRequestHandler):
|
|
141
|
+
def do_GET(self): # noqa: N802 - stdlib signature
|
|
142
|
+
path, _, query = self.path.partition('?')
|
|
143
|
+
referer = self.headers.get('Referer') or ''
|
|
144
|
+
faulted = 'fault' in query or 'fault' in referer
|
|
145
|
+
demo = 'demo=1' in query or 'demo=1' in referer
|
|
146
|
+
if path == '/__dsh_a2a/state':
|
|
147
|
+
if faulted:
|
|
148
|
+
self._json(b'{"ok":false,"error":"injected fault"}', status=500)
|
|
149
|
+
else:
|
|
150
|
+
self._json(build_state(demo)) # regenerated: canvas writes stay visible
|
|
151
|
+
elif path == '/__dsh_a2a/canvas-layout':
|
|
152
|
+
doc = layout_load() if LAYOUT_FILE else layout_store['doc']
|
|
153
|
+
self._json(json.dumps({'ok': True, 'layout': doc}).encode())
|
|
154
|
+
else:
|
|
155
|
+
super().do_GET()
|
|
156
|
+
|
|
157
|
+
def do_POST(self): # noqa: N802 - stdlib signature
|
|
158
|
+
path, _, query = self.path.partition('?')
|
|
159
|
+
referer = self.headers.get('Referer') or ''
|
|
160
|
+
faulted = 'fault' in query or 'fault' in referer
|
|
161
|
+
if path not in ('/__dsh_a2a/canvas-layout', '/__dsh_a2a/canvas'):
|
|
162
|
+
self._json(b'{"ok":false,"error":"unknown route"}', status=404)
|
|
163
|
+
return
|
|
164
|
+
try:
|
|
165
|
+
length = int(self.headers.get('Content-Length') or 0)
|
|
166
|
+
raw = self.rfile.read(length) or b'{}'
|
|
167
|
+
if b'\xef\xbf\xbd' in raw: # U+FFFD guard, same as the host's text-guard
|
|
168
|
+
self._json(b'{"error": "text contains undecodable characters (U+FFFD) '
|
|
169
|
+
b'\xe2\x80\x94 fix the sender encoding and retry", "code": -32005}', status=422)
|
|
170
|
+
return
|
|
171
|
+
body = json.loads(raw)
|
|
172
|
+
except Exception: # noqa: BLE001 - malformed body mirrors the host 400
|
|
173
|
+
self._json(b'{"ok":false,"error":"malformed body"}', status=400)
|
|
174
|
+
return
|
|
175
|
+
if faulted:
|
|
176
|
+
self._json(b'{"ok":false,"error":"injected fault"}', status=500)
|
|
177
|
+
return
|
|
178
|
+
action = body.get('action')
|
|
179
|
+
if path == '/__dsh_a2a/canvas-layout':
|
|
180
|
+
self._canvas_layout(action, body)
|
|
181
|
+
else:
|
|
182
|
+
self._canvas(action, body)
|
|
183
|
+
|
|
184
|
+
def _canvas_layout(self, action, body):
|
|
185
|
+
if action == 'reset':
|
|
186
|
+
layout_store['doc'] = None
|
|
187
|
+
layout_persist(None)
|
|
188
|
+
self._json(b'{"ok":true,"layout":null}')
|
|
189
|
+
elif action == 'save':
|
|
190
|
+
layout_store['doc'] = body.get('layout')
|
|
191
|
+
layout_persist(layout_store['doc'])
|
|
192
|
+
self._json(json.dumps({'ok': True, 'layout': layout_store['doc']}).encode())
|
|
193
|
+
else:
|
|
194
|
+
self._json(b'{"ok":false,"error":"unknown action"}')
|
|
195
|
+
|
|
196
|
+
def _canvas(self, action, body):
|
|
197
|
+
if action == 'create':
|
|
198
|
+
name = valid_name(body.get('name'))
|
|
199
|
+
if name is None or (name not in teams and len(teams) >= CANVAS_TEAM_CAP):
|
|
200
|
+
self._json(b'{"ok":false,"error":"invalid name or team cap reached"}')
|
|
201
|
+
else:
|
|
202
|
+
teams.setdefault(name, [])
|
|
203
|
+
self._json(json.dumps({'ok': True, 'name': name, 'teams': list(teams)}).encode())
|
|
204
|
+
elif action == 'remove':
|
|
205
|
+
name = body.get('name')
|
|
206
|
+
teams.pop(name, None)
|
|
207
|
+
self._json(json.dumps({'ok': True, 'teams': list(teams)}).encode())
|
|
208
|
+
elif action == 'add-member':
|
|
209
|
+
name = valid_name(body.get('name'))
|
|
210
|
+
mid = body.get('id')
|
|
211
|
+
if name is None or not isinstance(mid, str) or mid not in joined_ids:
|
|
212
|
+
self._json(b'{"ok":false,"error":"name and a joined session id are required"}')
|
|
213
|
+
return
|
|
214
|
+
roster = teams.setdefault(name, [])
|
|
215
|
+
if mid in roster:
|
|
216
|
+
self._json(json.dumps({'ok': True, 'teams': list(teams), 'members': list(roster)}).encode())
|
|
217
|
+
elif len(roster) >= CANVAS_MEMBER_CAP:
|
|
218
|
+
self._json(json.dumps({'ok': False, 'teams': list(teams), 'members': list(roster)}).encode())
|
|
219
|
+
else:
|
|
220
|
+
roster.append(mid)
|
|
221
|
+
self._json(json.dumps({'ok': True, 'teams': list(teams), 'members': list(roster)}).encode())
|
|
222
|
+
elif action == 'remove-member':
|
|
223
|
+
name = body.get('name')
|
|
224
|
+
mid = body.get('id')
|
|
225
|
+
if not isinstance(name, str) or not name.strip() or not isinstance(mid, str):
|
|
226
|
+
self._json(b'{"ok":false,"error":"name and id are required"}')
|
|
227
|
+
return
|
|
228
|
+
roster = teams.get(name)
|
|
229
|
+
if roster is None:
|
|
230
|
+
self._json(json.dumps({'ok': False, 'teams': list(teams), 'members': []}).encode())
|
|
231
|
+
elif mid in roster:
|
|
232
|
+
roster.remove(mid)
|
|
233
|
+
self._json(json.dumps({'ok': True, 'teams': list(teams), 'members': list(roster)}).encode())
|
|
234
|
+
else:
|
|
235
|
+
self._json(json.dumps({'ok': False, 'teams': list(teams), 'members': list(roster)}).encode())
|
|
236
|
+
else:
|
|
237
|
+
self._json(b'{"ok":false,"error":"unknown action"}')
|
|
238
|
+
|
|
239
|
+
def _json(self, payload: bytes, status: int = 200):
|
|
240
|
+
self.send_response(status)
|
|
241
|
+
self.send_header('Content-Type', 'application/json')
|
|
242
|
+
self.send_header('Content-Length', str(len(payload)))
|
|
243
|
+
self.send_header('Cache-Control', 'no-store')
|
|
244
|
+
self.end_headers()
|
|
245
|
+
self.wfile.write(payload)
|
|
246
|
+
|
|
247
|
+
def log_message(self, *args): # keep the console readable
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
handler = functools.partial(MockHost, directory=str(DIST))
|
|
252
|
+
print(f'mock host on http://127.0.0.1:{PORT}/ (dist: {DIST})', flush=True)
|
|
253
|
+
ThreadingHTTPServer(('127.0.0.1', PORT), handler).serve_forever()
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas action algebra for PR C (design.md §3.3/§3.4): the five user-facing
|
|
3
|
+
* write actions as optimistic model mutations that each return a TEAM-SCOPED
|
|
4
|
+
* undo. Scoped undos are the feedback loop's core - a whole-state revert
|
|
5
|
+
* would clobber other queued actions' optimistic state; a per-team undo only
|
|
6
|
+
* ever touches the team whose write failed, so queued actions on other teams
|
|
7
|
+
* survive, and undo state (captured at apply time) stays consistent with the
|
|
8
|
+
* per-team serial settle order.
|
|
9
|
+
*
|
|
10
|
+
* Pure: no DOM, no three, no fetch. The wire (canvas-wire.ts) owns transport;
|
|
11
|
+
* the view owns calling applyAction before emitting and undo() on failure.
|
|
12
|
+
* @module nexus-stage/canvas-ops
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { nodeRect, deriveInitialFrame, type WorldModel } from './world'
|
|
16
|
+
|
|
17
|
+
/** One pre-computed host op (remove = remove-member, add = add-member). */
|
|
18
|
+
export type RosterOp = { op: 'remove' | 'add'; id: string }
|
|
19
|
+
|
|
20
|
+
/** The five user-facing write actions (one per design.md §3.3 row), plus the two network join/leave actions the panel-slim migration moved into the planning view. */
|
|
21
|
+
export type CanvasAction =
|
|
22
|
+
| { type: 'create-team'; name: string; ids: readonly string[]; /** true when the model had no such team before this action (a serial failure then leaves a ghost EMPTY team on the host — the wire compensates with a remove). */ created?: boolean }
|
|
23
|
+
| { type: 'add-member'; team: string; ids: readonly string[] }
|
|
24
|
+
| { type: 'remove-member'; team: string; ids: readonly string[] }
|
|
25
|
+
| { type: 'remove-team'; name: string }
|
|
26
|
+
/** `ops` comes from reorderOps() at emit time; the wire stays model-free. */
|
|
27
|
+
| { type: 'reorder'; team: string; ops: ReadonlyArray<RosterOp> }
|
|
28
|
+
/** Network-level join/leave (the session joins the A2A mesh) — distinct from canvas-team membership. */
|
|
29
|
+
| { type: 'join-network'; id: string }
|
|
30
|
+
| { type: 'leave-network'; id: string }
|
|
31
|
+
|
|
32
|
+
/** Reverts exactly what one applyAction did (team-scoped). */
|
|
33
|
+
export type Undo = () => void
|
|
34
|
+
|
|
35
|
+
/** The team whose writes this action touches (queue key for the wire). */
|
|
36
|
+
export function actionTeam(a: CanvasAction): string {
|
|
37
|
+
// Network join/leave are session-scoped, not team-scoped: one shared
|
|
38
|
+
// queue key keeps them serial against each other.
|
|
39
|
+
if (a.type === 'join-network' || a.type === 'leave-network') return '$network'
|
|
40
|
+
return a.type === 'create-team' || a.type === 'remove-team' ? a.name : a.team
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Apply one action's optimistic mutation and return its undo. The undo is
|
|
45
|
+
* expressed as the INVERSE DELTA against the roster at undo time, never as
|
|
46
|
+
* a wholesale restore of a captured prefix - a later queued action on the
|
|
47
|
+
* same team may have applied optimistically before this action's write
|
|
48
|
+
* fails, and rolling back must remove only this action's effect.
|
|
49
|
+
*/
|
|
50
|
+
export function applyAction(model: WorldModel, a: CanvasAction): Undo {
|
|
51
|
+
if (a.type === 'create-team') {
|
|
52
|
+
const rects = a.ids
|
|
53
|
+
.map(id => model.getNode(id))
|
|
54
|
+
.filter(n => n !== undefined)
|
|
55
|
+
.map(n => nodeRect(n))
|
|
56
|
+
// Ids not on the canvas yet (a create emitted from the node list for an
|
|
57
|
+
// unteamed session) resolve no rect: skip the optimistic frame so the
|
|
58
|
+
// next poll derives it from the member's real seat instead of pinning a
|
|
59
|
+
// 260x160 placeholder at the origin.
|
|
60
|
+
if (rects.length > 0) model.setFrame(a.name, deriveInitialFrame(rects))
|
|
61
|
+
model.setTeamMembers(a.name, a.ids)
|
|
62
|
+
return () => {
|
|
63
|
+
// Only the created ids (a later add to this new team must survive).
|
|
64
|
+
model.setTeamMembers(a.name, model.teamMemberIds(a.name).filter(id => !a.ids.includes(id)))
|
|
65
|
+
model.removeFrame(a.name)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (a.type === 'add-member') {
|
|
69
|
+
const pre = model.teamMemberIds(a.team)
|
|
70
|
+
const fresh = a.ids.filter(id => !pre.includes(id))
|
|
71
|
+
model.setTeamMembers(a.team, [...pre, ...fresh])
|
|
72
|
+
return () => {
|
|
73
|
+
model.setTeamMembers(a.team, model.teamMemberIds(a.team).filter(id => !fresh.includes(id)))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (a.type === 'remove-member') {
|
|
77
|
+
const pre = model.teamMemberIds(a.team)
|
|
78
|
+
model.setTeamMembers(a.team, pre.filter(id => !a.ids.includes(id)))
|
|
79
|
+
return () => {
|
|
80
|
+
// Re-append the removed ids that nothing else has re-added since.
|
|
81
|
+
const current = model.teamMemberIds(a.team)
|
|
82
|
+
model.setTeamMembers(a.team, [...current, ...a.ids.filter(id => !current.includes(id))])
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (a.type === 'remove-team') {
|
|
86
|
+
const preIds = model.teamMemberIds(a.name)
|
|
87
|
+
const frame = model.getFrame(a.name)
|
|
88
|
+
const preRect = frame !== undefined ? { ...frame } : undefined
|
|
89
|
+
model.setTeamMembers(a.name, [])
|
|
90
|
+
model.removeFrame(a.name)
|
|
91
|
+
return () => {
|
|
92
|
+
if (preRect !== undefined) model.setFrame(a.name, preRect)
|
|
93
|
+
model.setTeamMembers(a.name, preIds)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Network join/leave: no optimistic canvas mutation — the next inventory
|
|
97
|
+
// poll absorbs the host's truth (the node appears/disappears on the
|
|
98
|
+
// reconcile after the host accepts), so the undo is a no-op.
|
|
99
|
+
if (a.type === 'join-network' || a.type === 'leave-network') {
|
|
100
|
+
return () => {}
|
|
101
|
+
}
|
|
102
|
+
// reorder: apply the pre-computed ops to the current roster (remove
|
|
103
|
+
// anywhere, append). The undo WALKS the current roster back to the
|
|
104
|
+
// captured pre-order (same append-host discipline), so members a later
|
|
105
|
+
// queued action added stay put.
|
|
106
|
+
const pre = model.teamMemberIds(a.team)
|
|
107
|
+
let roster = [...pre]
|
|
108
|
+
for (const op of a.ops) {
|
|
109
|
+
if (op.op === 'remove') roster = roster.filter(x => x !== op.id)
|
|
110
|
+
else roster.push(op.id)
|
|
111
|
+
}
|
|
112
|
+
model.setTeamMembers(a.team, roster)
|
|
113
|
+
return () => {
|
|
114
|
+
let back = model.teamMemberIds(a.team)
|
|
115
|
+
for (let i = 0; i < pre.length; i++) {
|
|
116
|
+
const id = pre[i]!
|
|
117
|
+
if (back[i] === id) continue
|
|
118
|
+
if (!back.includes(id)) continue
|
|
119
|
+
back = back.filter(x => x !== id)
|
|
120
|
+
back.push(id)
|
|
121
|
+
}
|
|
122
|
+
model.setTeamMembers(a.team, back)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The minimal remove+add walk that turns `current` into `desired` on a
|
|
128
|
+
* host whose add-member always APPENDS: walk desired positions in order;
|
|
129
|
+
* whenever the working copy disagrees, remove+re-add the desired member
|
|
130
|
+
* (host-side remove is positional, add appends, so the working copy mirrors
|
|
131
|
+
* the host roster exactly). Bounded at 2*|desired| writes and converges;
|
|
132
|
+
* an already-equal roster yields zero ops; desired ids absent from current
|
|
133
|
+
* are skipped (adding members is never reorder's business).
|
|
134
|
+
*/
|
|
135
|
+
export function reorderOps(
|
|
136
|
+
current: ReadonlyArray<string>,
|
|
137
|
+
desired: ReadonlyArray<string>,
|
|
138
|
+
): Array<{ op: 'remove' | 'add'; id: string }> {
|
|
139
|
+
const working = [...current]
|
|
140
|
+
const ops: Array<{ op: 'remove' | 'add'; id: string }> = []
|
|
141
|
+
for (let i = 0; i < desired.length; i++) {
|
|
142
|
+
const id = desired[i]!
|
|
143
|
+
if (working[i] === id) continue
|
|
144
|
+
if (!working.includes(id)) continue // not a member: not reorder's business
|
|
145
|
+
ops.push({ op: 'remove', id })
|
|
146
|
+
ops.push({ op: 'add', id })
|
|
147
|
+
working.splice(working.indexOf(id), 1)
|
|
148
|
+
working.push(id)
|
|
149
|
+
}
|
|
150
|
+
return ops
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** That team's members ordered by card-center y (ties: current priority). */
|
|
154
|
+
export function yOrderedMembers(model: WorldModel, team: string): string[] {
|
|
155
|
+
return model
|
|
156
|
+
.teamMemberIds(team)
|
|
157
|
+
.map(id => ({ id, y: model.getNode(id)?.y ?? 0 }))
|
|
158
|
+
.sort((a, b) => a.y - b.y)
|
|
159
|
+
.map(r => r.id)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Innermost (smallest-area) frame containing the world point, if any. */
|
|
163
|
+
export function innermostFrameAt(
|
|
164
|
+
frames: ReadonlyMap<string, { x: number; y: number; w: number; h: number }>,
|
|
165
|
+
p: { x: number; y: number },
|
|
166
|
+
): string | undefined {
|
|
167
|
+
let best: string | undefined
|
|
168
|
+
let bestArea = Number.POSITIVE_INFINITY
|
|
169
|
+
for (const [name, r] of frames) {
|
|
170
|
+
if (p.x < r.x || p.x > r.x + r.w || p.y < r.y || p.y > r.y + r.h) continue
|
|
171
|
+
const area = r.w * r.h
|
|
172
|
+
if (area < bestArea) { best = name; bestArea = area }
|
|
173
|
+
}
|
|
174
|
+
return best
|
|
175
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canvas write face's transport layer (POST /__dsh_a2a/canvas): action
|
|
3
|
+
* sequencing, per-team serialization, and the notice classification that
|
|
4
|
+
* design.md §3.4 mandates (host error text surfaces VERBATIM - the sidebar
|
|
5
|
+
* reference's swallowed `.catch(() => {})` is the anti-pattern here).
|
|
6
|
+
*
|
|
7
|
+
* Classification per request:
|
|
8
|
+
* - `ok:true` → success (silent)
|
|
9
|
+
* - `ok:false` WITH an `error` string → error notice, verbatim
|
|
10
|
+
* - `ok:false` without `error` (absent target, → idempotent no-op,
|
|
11
|
+
* member cap hit) silent success
|
|
12
|
+
* - HTTP >= 400 → body error verbatim, else `HTTP <status>`
|
|
13
|
+
* - send() throws → `画布不可达:<message>`
|
|
14
|
+
* - 200 + ok:false WITHOUT an error string → idempotent no-op
|
|
15
|
+
* (absent target, member/team cap hit) — silent success
|
|
16
|
+
* - 200 + non-JSON body (proxy artifact) → classified as success
|
|
17
|
+
* (known limitation: nothing parseable to classify)
|
|
18
|
+
*
|
|
19
|
+
* Ops on the same team run through a serial queue (a later gesture waits
|
|
20
|
+
* instead of racing; cross-team ops run in parallel) - mirrors the
|
|
21
|
+
* sidebar's per-team busy keys, minus the rejection hostility. Pure
|
|
22
|
+
* control flow: the transport is injected.
|
|
23
|
+
* @module nexus-stage/canvas-wire
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** One send outcome: HTTP status plus the parsed JSON body (whatever it is). */
|
|
27
|
+
export interface CanvasWireResult {
|
|
28
|
+
status: number
|
|
29
|
+
body: { ok?: boolean; error?: string; teams?: readonly string[]; members?: readonly string[]; name?: string }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Transport injection: POST the body, resolve with parsed outcome, throw on network failure. */
|
|
33
|
+
export type CanvasSend = (body: unknown) => Promise<CanvasWireResult>
|
|
34
|
+
|
|
35
|
+
export interface CanvasWireDeps {
|
|
36
|
+
send: CanvasSend
|
|
37
|
+
onNotice(kind: 'error' | 'info', text: string): void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CanvasWire {
|
|
41
|
+
/** create → add-member per id, serial, first failure stops. `created`:
|
|
42
|
+
* the team did not exist model-side before this action — a first-add
|
|
43
|
+
* failure would then leave a ghost EMPTY team on the host, compensated
|
|
44
|
+
* with a best-effort remove. */
|
|
45
|
+
createTeam(name: string, ids: readonly string[], created?: boolean): Promise<boolean>
|
|
46
|
+
addMembers(team: string, ids: readonly string[]): Promise<boolean>
|
|
47
|
+
removeMembers(team: string, ids: readonly string[]): Promise<boolean>
|
|
48
|
+
/** Pre-computed remove/add roster ops (from canvas-ops.reorderOps). */
|
|
49
|
+
runRosterOps(team: string, ops: ReadonlyArray<{ op: 'remove' | 'add'; id: string }>): Promise<boolean>
|
|
50
|
+
removeTeam(name: string): Promise<boolean>
|
|
51
|
+
hasPending(): boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type RosterOp = { op: 'remove' | 'add'; id: string }
|
|
55
|
+
|
|
56
|
+
function errorText(body: CanvasWireResult['body']): string | undefined {
|
|
57
|
+
return typeof body.error === 'string' ? body.error : undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createCanvasWire(deps: CanvasWireDeps): CanvasWire {
|
|
61
|
+
const queues = new Map<string, Promise<unknown>>()
|
|
62
|
+
let pending = 0
|
|
63
|
+
|
|
64
|
+
/** Serial per-team queue: same key waits, other keys run in parallel. */
|
|
65
|
+
function enqueue<T>(key: string, task: () => Promise<T>): Promise<T> {
|
|
66
|
+
pending += 1
|
|
67
|
+
const prev = queues.get(key) ?? Promise.resolve()
|
|
68
|
+
const run = async (): Promise<T> => {
|
|
69
|
+
try {
|
|
70
|
+
return await task()
|
|
71
|
+
} finally {
|
|
72
|
+
pending -= 1 // settles BEFORE the returned promise resolves
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const next = prev.then(run, run) // run regardless of how the predecessor settled
|
|
76
|
+
queues.set(
|
|
77
|
+
key,
|
|
78
|
+
next.then(
|
|
79
|
+
() => undefined,
|
|
80
|
+
() => undefined,
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
return next
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One classified request — NEVER enqueues (public ops own the queue). */
|
|
87
|
+
async function raw(body: Record<string, unknown>): Promise<boolean> {
|
|
88
|
+
let res: CanvasWireResult
|
|
89
|
+
try {
|
|
90
|
+
res = await deps.send(body)
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const message = String((error as Error | undefined)?.message ?? error).slice(0, 80)
|
|
93
|
+
deps.onNotice('error', `画布不可达:${message}`)
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
if (res.status >= 400) {
|
|
97
|
+
deps.onNotice('error', errorText(res.body) ?? `HTTP ${res.status}`)
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
if (res.body.ok === true) return true
|
|
101
|
+
const text = errorText(res.body)
|
|
102
|
+
if (text !== undefined) {
|
|
103
|
+
deps.onNotice('error', text)
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
return true // ok:false without error: idempotent no-op (absent target / cap)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function serial(body: Record<string, unknown>, ids: readonly string[]): Promise<boolean> {
|
|
110
|
+
for (const id of ids) {
|
|
111
|
+
if (!(await raw({ ...body, id }))) return false
|
|
112
|
+
}
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
createTeam(name, ids, created = false) {
|
|
118
|
+
return enqueue(name, async () => {
|
|
119
|
+
if (!(await raw({ action: 'create', name }))) return false
|
|
120
|
+
for (let i = 0; i < ids.length; i++) {
|
|
121
|
+
if (!(await raw({ action: 'add-member', name, id: ids[i]! }))) {
|
|
122
|
+
// A failure on the FIRST add of a team this action created leaves
|
|
123
|
+
// a ghost EMPTY team on the host — compensate with a remove.
|
|
124
|
+
// Later failures keep whatever members genuinely joined.
|
|
125
|
+
if (created && i === 0) await raw({ action: 'remove', name })
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return true
|
|
130
|
+
})
|
|
131
|
+
},
|
|
132
|
+
addMembers(team, ids) {
|
|
133
|
+
return enqueue(team, () => serial({ action: 'add-member', name: team }, ids))
|
|
134
|
+
},
|
|
135
|
+
removeMembers(team, ids) {
|
|
136
|
+
return enqueue(team, () => serial({ action: 'remove-member', name: team }, ids))
|
|
137
|
+
},
|
|
138
|
+
runRosterOps(team, ops) {
|
|
139
|
+
return enqueue(team, async () => {
|
|
140
|
+
for (const op of ops) {
|
|
141
|
+
const body = op.op === 'remove'
|
|
142
|
+
? { action: 'remove-member', name: team, id: op.id }
|
|
143
|
+
: { action: 'add-member', name: team, id: op.id }
|
|
144
|
+
if (!(await raw(body))) return false
|
|
145
|
+
}
|
|
146
|
+
return true
|
|
147
|
+
})
|
|
148
|
+
},
|
|
149
|
+
removeTeam(name) {
|
|
150
|
+
return enqueue(name, () => raw({ action: 'remove', name }))
|
|
151
|
+
},
|
|
152
|
+
hasPending() {
|
|
153
|
+
return pending > 0
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Star-shaped membership edges for the 2D planning canvas: one spoke per
|
|
3
|
+
* (team frame -> member card), replacing the O(n^2) pairwise mesh. All
|
|
4
|
+
* geometry is world-space so the SVG layer can emit raw coordinates - the
|
|
5
|
+
* transformed #world container carries them to the screen for free.
|
|
6
|
+
*
|
|
7
|
+
* Anchor contract (must stay in lockstep with planning.css):
|
|
8
|
+
* - origin = the frame titlebar's bottom-edge midpoint. The head is a
|
|
9
|
+
* 24px border-box straddling the frame's top border at top:-13px, so
|
|
10
|
+
* its bottom edge sits at fy + 11, horizontally centered at fx + fw/2.
|
|
11
|
+
* - target = the member card's top-edge midpoint {nx, ny - NODE_H/2}.
|
|
12
|
+
*
|
|
13
|
+
* Cross-team members draw solid from their first membership (state order)
|
|
14
|
+
* and dashed from every later one - the many-to-many invariant made
|
|
15
|
+
* visible. Edges are derived only; there is no freehand edge in this
|
|
16
|
+
* canvas. Pure: no DOM, no three.
|
|
17
|
+
* @module nexus-stage/edges
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { NODE_H } from './world'
|
|
21
|
+
import type { FrameGeom } from './world'
|
|
22
|
+
|
|
23
|
+
/** Titlebar border-box height (planning.css .frame-head must match). */
|
|
24
|
+
export const FRAME_HEAD_H = 24
|
|
25
|
+
/** Titlebar CSS top offset, negative - straddles the frame's top border. */
|
|
26
|
+
export const FRAME_HEAD_TOP = -13
|
|
27
|
+
/** Origin anchor: frame top + the head's bottom edge offset. */
|
|
28
|
+
export const FRAME_HEAD_LIFT = FRAME_HEAD_TOP + FRAME_HEAD_H
|
|
29
|
+
|
|
30
|
+
/** Minimal team shape (topology.CanvasTeam satisfies it). */
|
|
31
|
+
export interface EdgeTeam { name: string; members: ReadonlyArray<{ id: string }> }
|
|
32
|
+
|
|
33
|
+
/** One derived spoke, ready for the SVG layer. */
|
|
34
|
+
export interface StarEdge {
|
|
35
|
+
x1: number
|
|
36
|
+
y1: number
|
|
37
|
+
x2: number
|
|
38
|
+
y2: number
|
|
39
|
+
/** True from a member's second membership on (cross-team many-to-many). */
|
|
40
|
+
dashed: boolean
|
|
41
|
+
/** Team name and member id, for keyed diffing. */
|
|
42
|
+
team: string
|
|
43
|
+
id: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compute every membership spoke for the current world. Frames without a
|
|
48
|
+
* rect and member ids without a position are skipped - dangling rows are
|
|
49
|
+
* a polling transient, not an error.
|
|
50
|
+
*/
|
|
51
|
+
export function starEdges(
|
|
52
|
+
teams: ReadonlyArray<EdgeTeam>,
|
|
53
|
+
frames: ReadonlyMap<string, FrameGeom>,
|
|
54
|
+
positions: ReadonlyMap<string, { readonly x: number; readonly y: number }>,
|
|
55
|
+
): StarEdge[] {
|
|
56
|
+
const out: StarEdge[] = []
|
|
57
|
+
const seen = new Set<string>()
|
|
58
|
+
for (const team of teams) {
|
|
59
|
+
const rect = frames.get(team.name)
|
|
60
|
+
if (rect === undefined) continue
|
|
61
|
+
const x1 = rect.x + rect.w / 2
|
|
62
|
+
const y1 = rect.y + FRAME_HEAD_LIFT
|
|
63
|
+
for (const member of team.members) {
|
|
64
|
+
const pos = positions.get(member.id)
|
|
65
|
+
if (pos === undefined) continue
|
|
66
|
+
const dashed = seen.has(member.id)
|
|
67
|
+
seen.add(member.id)
|
|
68
|
+
out.push({ x1, y1, x2: pos.x, y2: pos.y - NODE_H / 2, dashed, team: team.name, id: member.id })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|