@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.
- package/README.md +2 -1
- package/README_zh.md +2 -1
- package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
- package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
- package/assets/stageDist/index.html +12 -12
- package/lib/a2a-client.js +3 -2
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/config.js +1 -0
- package/lib/faces/files.js +2 -2
- package/lib/faces/session-log.js +3 -3
- package/lib/faces/wire.js +11 -16
- package/lib/index.js +102 -18
- package/lib/link-keys.js +29 -0
- package/lib/origin-claim.js +4 -0
- package/lib/skill/SKILL.md +117 -117
- package/lib/skill/references/collaboration.md +428 -428
- package/lib/skill/references/discovery.md +78 -78
- package/lib/skill/references/intake.md +107 -107
- package/lib/skill/references/membership.md +73 -73
- package/lib/skill/references/pitfalls.md +51 -51
- package/lib/skill/references/router.md +96 -96
- package/lib/skill/references/routing.md +65 -65
- package/lib/skill/references/setup.md +5 -1
- package/lib/tools/files.js +3 -1
- package/lib/types/a2a-client.d.ts +3 -1
- package/lib/types/config.d.ts +6 -0
- package/lib/types/faces/files.d.ts +2 -1
- package/lib/types/faces/session-log.d.ts +2 -0
- package/lib/types/faces/wire.d.ts +2 -1
- package/lib/types/link-keys.d.ts +13 -0
- package/lib/types/origin-claim.d.ts +2 -0
- package/lib/types/tools/files.d.ts +1 -1
- package/nexus-stage/package.json +21 -21
- package/nexus-stage/scripts/mockhost.py +261 -261
- package/nexus-stage/src/canvas-ops.ts +191 -191
- package/nexus-stage/src/canvas-wire.ts +164 -164
- package/nexus-stage/src/cli-home-picker.ts +42 -42
- package/nexus-stage/src/fault.ts +56 -56
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +241 -241
- package/nexus-stage/src/layout-doc.ts +145 -145
- package/nexus-stage/src/main.ts +513 -513
- package/nexus-stage/src/planning-card-text.ts +73 -73
- package/nexus-stage/src/planning-card.ts +63 -63
- package/nexus-stage/src/planning-edges.ts +26 -26
- package/nexus-stage/src/planning-fit.ts +45 -45
- package/nexus-stage/src/planning-frame-hue.ts +13 -13
- package/nexus-stage/src/planning-legend.ts +39 -39
- package/nexus-stage/src/planning-member-handles.ts +61 -61
- package/nexus-stage/src/planning-menus.ts +422 -422
- package/nexus-stage/src/planning-mirror.ts +75 -75
- package/nexus-stage/src/planning-nodelist.ts +294 -294
- package/nexus-stage/src/planning-overlap.ts +159 -159
- package/nexus-stage/src/planning-reachability.ts +28 -28
- package/nexus-stage/src/planning-relayout.ts +121 -121
- package/nexus-stage/src/planning-roster.ts +15 -15
- package/nexus-stage/src/planning-search.ts +168 -168
- package/nexus-stage/src/planning-types.ts +115 -115
- package/nexus-stage/src/planning-undo.ts +68 -68
- package/nexus-stage/src/planning-view.ts +1499 -1499
- package/nexus-stage/src/planning.css +340 -340
- package/nexus-stage/src/seat-name-editor.ts +31 -31
- package/nexus-stage/src/session-drag.ts +64 -64
- package/nexus-stage/src/viewport.ts +106 -106
- package/nexus-stage/src/wb-chat.ts +684 -684
- package/nexus-stage/src/wb-directory.ts +237 -237
- package/nexus-stage/src/wb-ledger.ts +279 -279
- package/nexus-stage/src/wb-names.ts +43 -43
- package/nexus-stage/src/wb-rail.ts +147 -147
- package/nexus-stage/src/workbench.css +388 -388
- package/nexus-stage/src/workbench.ts +1272 -1272
- package/nexus-stage/src/world.ts +509 -509
- package/nexus-stage/tsconfig.json +24 -24
- package/nexus-stage/vite.config.ts +15 -15
- package/package.json +1 -1
package/nexus-stage/package.json
CHANGED
|
@@ -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()
|