@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.
Files changed (86) hide show
  1. package/README.md +243 -121
  2. package/README_zh.md +144 -84
  3. package/assets/nexusDist/assets/index-034h1cNc.js +3 -0
  4. package/assets/nexusDist/assets/index-PG9EieGX.css +1 -0
  5. package/assets/nexusDist/index.html +26 -0
  6. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -0
  7. package/assets/stageDist/assets/index-C-28k_o5.js +974 -0
  8. package/assets/stageDist/assets/interiors-CQGBjffT.png +0 -0
  9. package/assets/stageDist/assets/office-tileset-CHQT-uYL.png +0 -0
  10. package/assets/stageDist/index.html +13 -0
  11. package/lib/a2a-client.js +190 -13
  12. package/lib/canvas-store.js +218 -0
  13. package/lib/card.js +25 -0
  14. package/lib/client/A2aControl.js +45 -137
  15. package/lib/client/locales.js +16 -40
  16. package/lib/client.js +164 -467
  17. package/lib/client.js.map +1 -1
  18. package/lib/group-store.js +6 -0
  19. package/lib/idempotency-store.js +291 -0
  20. package/lib/index.js +4217 -259
  21. package/lib/joined-store.js +11 -0
  22. package/lib/layout-store.js +155 -0
  23. package/lib/peer-store.js +98 -13
  24. package/lib/receipt-ladder.js +35 -0
  25. package/lib/receipt.js +102 -0
  26. package/lib/self-suppress.js +62 -0
  27. package/lib/session-transcript.js +150 -0
  28. package/lib/skill/SKILL.md +77 -0
  29. package/lib/skill/references/discovery.md +38 -0
  30. package/lib/skill/references/membership.md +50 -0
  31. package/lib/skill/references/pitfalls.md +42 -0
  32. package/lib/skill/references/routing.md +43 -0
  33. package/lib/skill-registration.js +52 -0
  34. package/lib/stage-mount.js +32 -0
  35. package/lib/task-ledger.js +540 -0
  36. package/lib/team-store.js +122 -0
  37. package/lib/teams-bridge.js +18 -0
  38. package/lib/transport-caps.js +31 -0
  39. package/lib/types/a2a-client.d.ts +72 -1
  40. package/lib/types/canvas-store.d.ts +102 -0
  41. package/lib/types/client/A2aControl.d.ts +18 -28
  42. package/lib/types/client/locales.d.ts +1 -1
  43. package/lib/types/group-store.d.ts +6 -0
  44. package/lib/types/idempotency-store.d.ts +164 -0
  45. package/lib/types/index.d.ts +210 -6
  46. package/lib/types/joined-store.d.ts +9 -0
  47. package/lib/types/layout-store.d.ts +63 -0
  48. package/lib/types/peer-store.d.ts +45 -2
  49. package/lib/types/receipt-ladder.d.ts +25 -0
  50. package/lib/types/receipt.d.ts +51 -0
  51. package/lib/types/self-suppress.d.ts +41 -0
  52. package/lib/types/session-transcript.d.ts +37 -0
  53. package/lib/types/skill-registration.d.ts +3 -0
  54. package/lib/types/stage-mount.d.ts +29 -0
  55. package/lib/types/task-ledger.d.ts +308 -0
  56. package/lib/types/team-store.d.ts +55 -0
  57. package/lib/types/teams-bridge.d.ts +112 -0
  58. package/lib/types/transport-caps.d.ts +29 -0
  59. package/lib/types/types.d.ts +43 -0
  60. package/nexus-stage/package-lock.json +2262 -0
  61. package/nexus-stage/package.json +21 -0
  62. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
  63. package/nexus-stage/scripts/mockhost.py +253 -0
  64. package/nexus-stage/src/canvas-ops.ts +175 -0
  65. package/nexus-stage/src/canvas-wire.ts +156 -0
  66. package/nexus-stage/src/edges.ts +72 -0
  67. package/nexus-stage/src/fault.ts +56 -0
  68. package/nexus-stage/src/federation.ts +234 -0
  69. package/nexus-stage/src/layout-doc.ts +143 -0
  70. package/nexus-stage/src/layout-wire.ts +158 -0
  71. package/nexus-stage/src/main.ts +361 -0
  72. package/nexus-stage/src/planning-menus.ts +352 -0
  73. package/nexus-stage/src/planning-nodelist.ts +294 -0
  74. package/nexus-stage/src/planning-types.ts +111 -0
  75. package/nexus-stage/src/planning-view.ts +1313 -0
  76. package/nexus-stage/src/planning.css +280 -0
  77. package/nexus-stage/src/seat.ts +35 -0
  78. package/nexus-stage/src/stage-name.ts +13 -0
  79. package/nexus-stage/src/tokens.ts +32 -0
  80. package/nexus-stage/src/viewport.ts +83 -0
  81. package/nexus-stage/src/workbench.css +267 -0
  82. package/nexus-stage/src/workbench.ts +1530 -0
  83. package/nexus-stage/src/world.ts +509 -0
  84. package/nexus-stage/tsconfig.json +25 -0
  85. package/nexus-stage/vite.config.ts +16 -0
  86. 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
+ }
@@ -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
+ }