@nelsonlongxiang/dsh-open-a2a-net 0.6.40 → 0.6.42

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 (76) hide show
  1. package/README.md +2 -1
  2. package/README_zh.md +2 -1
  3. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
  4. package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
  5. package/assets/stageDist/index.html +12 -12
  6. package/lib/a2a-client.js +3 -2
  7. package/lib/client.js +64 -64
  8. package/lib/client.js.map +1 -1
  9. package/lib/config.js +1 -0
  10. package/lib/faces/files.js +2 -2
  11. package/lib/faces/session-log.js +3 -3
  12. package/lib/faces/wire.js +11 -16
  13. package/lib/index.js +102 -18
  14. package/lib/link-keys.js +29 -0
  15. package/lib/origin-claim.js +4 -0
  16. package/lib/skill/SKILL.md +117 -117
  17. package/lib/skill/references/collaboration.md +428 -428
  18. package/lib/skill/references/discovery.md +78 -78
  19. package/lib/skill/references/intake.md +107 -107
  20. package/lib/skill/references/membership.md +73 -73
  21. package/lib/skill/references/pitfalls.md +51 -51
  22. package/lib/skill/references/router.md +96 -96
  23. package/lib/skill/references/routing.md +65 -65
  24. package/lib/skill/references/setup.md +5 -1
  25. package/lib/tools/files.js +3 -1
  26. package/lib/types/a2a-client.d.ts +3 -1
  27. package/lib/types/config.d.ts +6 -0
  28. package/lib/types/faces/files.d.ts +2 -1
  29. package/lib/types/faces/session-log.d.ts +2 -0
  30. package/lib/types/faces/wire.d.ts +2 -1
  31. package/lib/types/link-keys.d.ts +13 -0
  32. package/lib/types/origin-claim.d.ts +2 -0
  33. package/lib/types/tools/files.d.ts +1 -1
  34. package/nexus-stage/package.json +21 -21
  35. package/nexus-stage/scripts/mockhost.py +261 -261
  36. package/nexus-stage/src/canvas-ops.ts +191 -191
  37. package/nexus-stage/src/canvas-wire.ts +164 -164
  38. package/nexus-stage/src/cli-home-picker.ts +42 -42
  39. package/nexus-stage/src/fault.ts +56 -56
  40. package/nexus-stage/src/federation-overlay.ts +103 -103
  41. package/nexus-stage/src/federation.ts +241 -241
  42. package/nexus-stage/src/layout-doc.ts +145 -145
  43. package/nexus-stage/src/main.ts +513 -513
  44. package/nexus-stage/src/planning-card-text.ts +73 -73
  45. package/nexus-stage/src/planning-card.ts +63 -63
  46. package/nexus-stage/src/planning-edges.ts +26 -26
  47. package/nexus-stage/src/planning-fit.ts +45 -45
  48. package/nexus-stage/src/planning-frame-hue.ts +13 -13
  49. package/nexus-stage/src/planning-legend.ts +39 -39
  50. package/nexus-stage/src/planning-member-handles.ts +61 -61
  51. package/nexus-stage/src/planning-menus.ts +422 -422
  52. package/nexus-stage/src/planning-mirror.ts +75 -75
  53. package/nexus-stage/src/planning-nodelist.ts +294 -294
  54. package/nexus-stage/src/planning-overlap.ts +159 -159
  55. package/nexus-stage/src/planning-reachability.ts +28 -28
  56. package/nexus-stage/src/planning-relayout.ts +121 -121
  57. package/nexus-stage/src/planning-roster.ts +15 -15
  58. package/nexus-stage/src/planning-search.ts +168 -168
  59. package/nexus-stage/src/planning-types.ts +115 -115
  60. package/nexus-stage/src/planning-undo.ts +68 -68
  61. package/nexus-stage/src/planning-view.ts +1499 -1499
  62. package/nexus-stage/src/planning.css +340 -340
  63. package/nexus-stage/src/seat-name-editor.ts +31 -31
  64. package/nexus-stage/src/session-drag.ts +64 -64
  65. package/nexus-stage/src/viewport.ts +106 -106
  66. package/nexus-stage/src/wb-chat.ts +684 -684
  67. package/nexus-stage/src/wb-directory.ts +237 -237
  68. package/nexus-stage/src/wb-ledger.ts +279 -279
  69. package/nexus-stage/src/wb-names.ts +43 -43
  70. package/nexus-stage/src/wb-rail.ts +147 -147
  71. package/nexus-stage/src/workbench.css +388 -388
  72. package/nexus-stage/src/workbench.ts +1272 -1272
  73. package/nexus-stage/src/world.ts +509 -509
  74. package/nexus-stage/tsconfig.json +24 -24
  75. package/nexus-stage/vite.config.ts +15 -15
  76. package/package.json +1 -1
@@ -1,21 +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
- }
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
+ }
@@ -1,261 +1,261 @@
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 # every member row, not only the first
87
- _p = _m.get('team', '').split('/')[0]
88
- if _p and '/' not in _m['id']:
89
- host_prefix = _p
90
-
91
-
92
- def valid_remote_ref(mid):
93
- """Mirror of canvas-store.validRemoteMemberRef: a '/'-qualified handle, no whitespace, no empty side."""
94
- return (isinstance(mid, str) and '/' in mid and mid == mid.strip() and not any(c.isspace() for c in mid)
95
- and not mid.startswith('/') and not mid.endswith('/') and '//' not in mid)
96
-
97
-
98
- def valid_name(raw):
99
- """Mirror of canvas-store.validName: trim, 1..40, no '/', not pure digits."""
100
- if not isinstance(raw, str):
101
- return None
102
- name = raw.strip()
103
- if name == '' or len(name) > CANVAS_NAME_MAX or '/' in name or name.isdigit():
104
- return None
105
- return name
106
-
107
-
108
- # ── demo federation dataset (PR D): merged into /__dsh_a2a/state only when the
109
- # request carries demo=1 (query or Referer), so the activity/federal layers are
110
- # demonstrable offline and on live fleets that have no in-flight routes. ──
111
- DEMO_PEERS = [
112
- {'url': 'http://192.168.3.88:3080', 'score': 42},
113
- {'url': 'http://10.0.0.7:13080', 'score': 17},
114
- ]
115
-
116
-
117
- def build_state(demo: bool = False) -> bytes:
118
- """The snapshot with canvas.teams regenerated from the mutable store."""
119
- state = dict(_BASE)
120
- team_rows = []
121
- for name, ids in teams.items():
122
- members = []
123
- for mid in ids:
124
- row = member_rows.get(mid)
125
- if row is None:
126
- # A remote ref renders as the real state face does: { id, team: the handle, remote: true }.
127
- row = ({'id': mid, 'team': mid, 'remote': True} if '/' in mid
128
- else {'id': mid, 'team': f'{host_prefix}/{mid[8:16] if mid.startswith("session-") else mid[:8]}', 'joined': True, 'live': True})
129
- member_rows[mid] = row
130
- members.append(row)
131
- team_rows.append({'name': name, 'team': f'{host_prefix}/canvas/{name}', 'members': members})
132
- state['canvas'] = {'teams': team_rows}
133
- if demo:
134
- # fresh list copies - _BASE's own peers must stay untouched
135
- state['peers'] = list(state.get('peers') or []) + [dict(p) for p in DEMO_PEERS]
136
- names = [row['name'] for row in team_rows]
137
- first = names[0] if names else 'dsh'
138
- second = names[1] if len(names) > 1 else first
139
- now_ms = int(time.time() * 1000)
140
- state['inFlight'] = [
141
- {'team': first, 'peer': '192.168.3.88:3080', 'startedAt': now_ms - 12000},
142
- {'team': second, 'peer': '10.0.0.7:13080', 'startedAt': now_ms - 45000},
143
- ]
144
- return json.dumps(state).encode()
145
-
146
-
147
- class MockHost(SimpleHTTPRequestHandler):
148
- def do_GET(self): # noqa: N802 - stdlib signature
149
- path, _, query = self.path.partition('?')
150
- referer = self.headers.get('Referer') or ''
151
- faulted = 'fault' in query or 'fault' in referer
152
- demo = 'demo=1' in query or 'demo=1' in referer
153
- if path == '/__dsh_a2a/state':
154
- if faulted:
155
- self._json(b'{"ok":false,"error":"injected fault"}', status=500)
156
- else:
157
- self._json(build_state(demo)) # regenerated: canvas writes stay visible
158
- elif path == '/__dsh_a2a/canvas-layout':
159
- doc = layout_load() if LAYOUT_FILE else layout_store['doc']
160
- self._json(json.dumps({'ok': True, 'layout': doc}).encode())
161
- else:
162
- super().do_GET()
163
-
164
- def do_POST(self): # noqa: N802 - stdlib signature
165
- path, _, query = self.path.partition('?')
166
- referer = self.headers.get('Referer') or ''
167
- faulted = 'fault' in query or 'fault' in referer
168
- if path not in ('/__dsh_a2a/canvas-layout', '/__dsh_a2a/canvas'):
169
- self._json(b'{"ok":false,"error":"unknown route"}', status=404)
170
- return
171
- try:
172
- length = int(self.headers.get('Content-Length') or 0)
173
- raw = self.rfile.read(length) or b'{}'
174
- if b'\xef\xbf\xbd' in raw: # U+FFFD guard, same as the host's text-guard
175
- self._json(b'{"error": "text contains undecodable characters (U+FFFD) '
176
- b'\xe2\x80\x94 fix the sender encoding and retry", "code": -32005}', status=422)
177
- return
178
- body = json.loads(raw)
179
- except Exception: # noqa: BLE001 - malformed body mirrors the host 400
180
- self._json(b'{"ok":false,"error":"malformed body"}', status=400)
181
- return
182
- if faulted:
183
- self._json(b'{"ok":false,"error":"injected fault"}', status=500)
184
- return
185
- action = body.get('action')
186
- if path == '/__dsh_a2a/canvas-layout':
187
- self._canvas_layout(action, body)
188
- else:
189
- self._canvas(action, body)
190
-
191
- def _canvas_layout(self, action, body):
192
- if action == 'reset':
193
- layout_store['doc'] = None
194
- layout_persist(None)
195
- self._json(b'{"ok":true,"layout":null}')
196
- elif action == 'save':
197
- layout_store['doc'] = body.get('layout')
198
- layout_persist(layout_store['doc'])
199
- self._json(json.dumps({'ok': True, 'layout': layout_store['doc']}).encode())
200
- else:
201
- self._json(b'{"ok":false,"error":"unknown action"}')
202
-
203
- def _canvas(self, action, body):
204
- if action == 'create':
205
- name = valid_name(body.get('name'))
206
- if name is None or (name not in teams and len(teams) >= CANVAS_TEAM_CAP):
207
- self._json(b'{"ok":false,"error":"invalid name or team cap reached"}')
208
- else:
209
- teams.setdefault(name, [])
210
- self._json(json.dumps({'ok': True, 'name': name, 'teams': list(teams)}).encode())
211
- elif action == 'remove':
212
- name = body.get('name')
213
- teams.pop(name, None)
214
- self._json(json.dumps({'ok': True, 'teams': list(teams)}).encode())
215
- elif action == 'add-member':
216
- name = valid_name(body.get('name'))
217
- mid = body.get('id')
218
- # Dual member shape, as the real host: a joined local session id, or a remote team handle.
219
- if name is None or not isinstance(mid, str) or not (mid in joined_ids or valid_remote_ref(mid)):
220
- self._json(b'{"ok":false,"error":"name and a member (joined session id, or a zone-qualified remote team handle like zone/a1b2c3d4) are required"}')
221
- return
222
- roster = teams.setdefault(name, [])
223
- if mid in roster:
224
- self._json(json.dumps({'ok': True, 'teams': list(teams), 'members': list(roster)}).encode())
225
- elif len(roster) >= CANVAS_MEMBER_CAP:
226
- self._json(json.dumps({'ok': False, 'teams': list(teams), 'members': list(roster)}).encode())
227
- else:
228
- roster.append(mid)
229
- self._json(json.dumps({'ok': True, 'teams': list(teams), 'members': list(roster)}).encode())
230
- elif action == 'remove-member':
231
- name = body.get('name')
232
- mid = body.get('id')
233
- if not isinstance(name, str) or not name.strip() or not isinstance(mid, str):
234
- self._json(b'{"ok":false,"error":"name and id are required"}')
235
- return
236
- roster = teams.get(name)
237
- if roster is None:
238
- self._json(json.dumps({'ok': False, 'teams': list(teams), 'members': []}).encode())
239
- elif mid in roster:
240
- roster.remove(mid)
241
- self._json(json.dumps({'ok': True, 'teams': list(teams), 'members': list(roster)}).encode())
242
- else:
243
- self._json(json.dumps({'ok': False, 'teams': list(teams), 'members': list(roster)}).encode())
244
- else:
245
- self._json(b'{"ok":false,"error":"unknown action"}')
246
-
247
- def _json(self, payload: bytes, status: int = 200):
248
- self.send_response(status)
249
- self.send_header('Content-Type', 'application/json')
250
- self.send_header('Content-Length', str(len(payload)))
251
- self.send_header('Cache-Control', 'no-store')
252
- self.end_headers()
253
- self.wfile.write(payload)
254
-
255
- def log_message(self, *args): # keep the console readable
256
- pass
257
-
258
-
259
- handler = functools.partial(MockHost, directory=str(DIST))
260
- print(f'mock host on http://127.0.0.1:{PORT}/ (dist: {DIST})', flush=True)
261
- ThreadingHTTPServer(('127.0.0.1', PORT), handler).serve_forever()
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 # every member row, not only the first
87
+ _p = _m.get('team', '').split('/')[0]
88
+ if _p and '/' not in _m['id']:
89
+ host_prefix = _p
90
+
91
+
92
+ def valid_remote_ref(mid):
93
+ """Mirror of canvas-store.validRemoteMemberRef: a '/'-qualified handle, no whitespace, no empty side."""
94
+ return (isinstance(mid, str) and '/' in mid and mid == mid.strip() and not any(c.isspace() for c in mid)
95
+ and not mid.startswith('/') and not mid.endswith('/') and '//' not in mid)
96
+
97
+
98
+ def valid_name(raw):
99
+ """Mirror of canvas-store.validName: trim, 1..40, no '/', not pure digits."""
100
+ if not isinstance(raw, str):
101
+ return None
102
+ name = raw.strip()
103
+ if name == '' or len(name) > CANVAS_NAME_MAX or '/' in name or name.isdigit():
104
+ return None
105
+ return name
106
+
107
+
108
+ # ── demo federation dataset (PR D): merged into /__dsh_a2a/state only when the
109
+ # request carries demo=1 (query or Referer), so the activity/federal layers are
110
+ # demonstrable offline and on live fleets that have no in-flight routes. ──
111
+ DEMO_PEERS = [
112
+ {'url': 'http://192.168.3.88:3080', 'score': 42},
113
+ {'url': 'http://10.0.0.7:13080', 'score': 17},
114
+ ]
115
+
116
+
117
+ def build_state(demo: bool = False) -> bytes:
118
+ """The snapshot with canvas.teams regenerated from the mutable store."""
119
+ state = dict(_BASE)
120
+ team_rows = []
121
+ for name, ids in teams.items():
122
+ members = []
123
+ for mid in ids:
124
+ row = member_rows.get(mid)
125
+ if row is None:
126
+ # A remote ref renders as the real state face does: { id, team: the handle, remote: true }.
127
+ row = ({'id': mid, 'team': mid, 'remote': True} if '/' in mid
128
+ else {'id': mid, 'team': f'{host_prefix}/{mid[8:16] if mid.startswith("session-") else mid[:8]}', 'joined': True, 'live': True})
129
+ member_rows[mid] = row
130
+ members.append(row)
131
+ team_rows.append({'name': name, 'team': f'{host_prefix}/canvas/{name}', 'members': members})
132
+ state['canvas'] = {'teams': team_rows}
133
+ if demo:
134
+ # fresh list copies - _BASE's own peers must stay untouched
135
+ state['peers'] = list(state.get('peers') or []) + [dict(p) for p in DEMO_PEERS]
136
+ names = [row['name'] for row in team_rows]
137
+ first = names[0] if names else 'dsh'
138
+ second = names[1] if len(names) > 1 else first
139
+ now_ms = int(time.time() * 1000)
140
+ state['inFlight'] = [
141
+ {'team': first, 'peer': '192.168.3.88:3080', 'startedAt': now_ms - 12000},
142
+ {'team': second, 'peer': '10.0.0.7:13080', 'startedAt': now_ms - 45000},
143
+ ]
144
+ return json.dumps(state).encode()
145
+
146
+
147
+ class MockHost(SimpleHTTPRequestHandler):
148
+ def do_GET(self): # noqa: N802 - stdlib signature
149
+ path, _, query = self.path.partition('?')
150
+ referer = self.headers.get('Referer') or ''
151
+ faulted = 'fault' in query or 'fault' in referer
152
+ demo = 'demo=1' in query or 'demo=1' in referer
153
+ if path == '/__dsh_a2a/state':
154
+ if faulted:
155
+ self._json(b'{"ok":false,"error":"injected fault"}', status=500)
156
+ else:
157
+ self._json(build_state(demo)) # regenerated: canvas writes stay visible
158
+ elif path == '/__dsh_a2a/canvas-layout':
159
+ doc = layout_load() if LAYOUT_FILE else layout_store['doc']
160
+ self._json(json.dumps({'ok': True, 'layout': doc}).encode())
161
+ else:
162
+ super().do_GET()
163
+
164
+ def do_POST(self): # noqa: N802 - stdlib signature
165
+ path, _, query = self.path.partition('?')
166
+ referer = self.headers.get('Referer') or ''
167
+ faulted = 'fault' in query or 'fault' in referer
168
+ if path not in ('/__dsh_a2a/canvas-layout', '/__dsh_a2a/canvas'):
169
+ self._json(b'{"ok":false,"error":"unknown route"}', status=404)
170
+ return
171
+ try:
172
+ length = int(self.headers.get('Content-Length') or 0)
173
+ raw = self.rfile.read(length) or b'{}'
174
+ if b'\xef\xbf\xbd' in raw: # U+FFFD guard, same as the host's text-guard
175
+ self._json(b'{"error": "text contains undecodable characters (U+FFFD) '
176
+ b'\xe2\x80\x94 fix the sender encoding and retry", "code": -32005}', status=422)
177
+ return
178
+ body = json.loads(raw)
179
+ except Exception: # noqa: BLE001 - malformed body mirrors the host 400
180
+ self._json(b'{"ok":false,"error":"malformed body"}', status=400)
181
+ return
182
+ if faulted:
183
+ self._json(b'{"ok":false,"error":"injected fault"}', status=500)
184
+ return
185
+ action = body.get('action')
186
+ if path == '/__dsh_a2a/canvas-layout':
187
+ self._canvas_layout(action, body)
188
+ else:
189
+ self._canvas(action, body)
190
+
191
+ def _canvas_layout(self, action, body):
192
+ if action == 'reset':
193
+ layout_store['doc'] = None
194
+ layout_persist(None)
195
+ self._json(b'{"ok":true,"layout":null}')
196
+ elif action == 'save':
197
+ layout_store['doc'] = body.get('layout')
198
+ layout_persist(layout_store['doc'])
199
+ self._json(json.dumps({'ok': True, 'layout': layout_store['doc']}).encode())
200
+ else:
201
+ self._json(b'{"ok":false,"error":"unknown action"}')
202
+
203
+ def _canvas(self, action, body):
204
+ if action == 'create':
205
+ name = valid_name(body.get('name'))
206
+ if name is None or (name not in teams and len(teams) >= CANVAS_TEAM_CAP):
207
+ self._json(b'{"ok":false,"error":"invalid name or team cap reached"}')
208
+ else:
209
+ teams.setdefault(name, [])
210
+ self._json(json.dumps({'ok': True, 'name': name, 'teams': list(teams)}).encode())
211
+ elif action == 'remove':
212
+ name = body.get('name')
213
+ teams.pop(name, None)
214
+ self._json(json.dumps({'ok': True, 'teams': list(teams)}).encode())
215
+ elif action == 'add-member':
216
+ name = valid_name(body.get('name'))
217
+ mid = body.get('id')
218
+ # Dual member shape, as the real host: a joined local session id, or a remote team handle.
219
+ if name is None or not isinstance(mid, str) or not (mid in joined_ids or valid_remote_ref(mid)):
220
+ self._json(b'{"ok":false,"error":"name and a member (joined session id, or a zone-qualified remote team handle like zone/a1b2c3d4) are required"}')
221
+ return
222
+ roster = teams.setdefault(name, [])
223
+ if mid in roster:
224
+ self._json(json.dumps({'ok': True, 'teams': list(teams), 'members': list(roster)}).encode())
225
+ elif len(roster) >= CANVAS_MEMBER_CAP:
226
+ self._json(json.dumps({'ok': False, 'teams': list(teams), 'members': list(roster)}).encode())
227
+ else:
228
+ roster.append(mid)
229
+ self._json(json.dumps({'ok': True, 'teams': list(teams), 'members': list(roster)}).encode())
230
+ elif action == 'remove-member':
231
+ name = body.get('name')
232
+ mid = body.get('id')
233
+ if not isinstance(name, str) or not name.strip() or not isinstance(mid, str):
234
+ self._json(b'{"ok":false,"error":"name and id are required"}')
235
+ return
236
+ roster = teams.get(name)
237
+ if roster is None:
238
+ self._json(json.dumps({'ok': False, 'teams': list(teams), 'members': []}).encode())
239
+ elif mid in roster:
240
+ roster.remove(mid)
241
+ self._json(json.dumps({'ok': True, 'teams': list(teams), 'members': list(roster)}).encode())
242
+ else:
243
+ self._json(json.dumps({'ok': False, 'teams': list(teams), 'members': list(roster)}).encode())
244
+ else:
245
+ self._json(b'{"ok":false,"error":"unknown action"}')
246
+
247
+ def _json(self, payload: bytes, status: int = 200):
248
+ self.send_response(status)
249
+ self.send_header('Content-Type', 'application/json')
250
+ self.send_header('Content-Length', str(len(payload)))
251
+ self.send_header('Cache-Control', 'no-store')
252
+ self.end_headers()
253
+ self.wfile.write(payload)
254
+
255
+ def log_message(self, *args): # keep the console readable
256
+ pass
257
+
258
+
259
+ handler = functools.partial(MockHost, directory=str(DIST))
260
+ print(f'mock host on http://127.0.0.1:{PORT}/ (dist: {DIST})', flush=True)
261
+ ThreadingHTTPServer(('127.0.0.1', PORT), handler).serve_forever()