dsh-engineering-ui 0.0.0-stage → 1.5.0

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 (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +772 -3
  3. package/engineering/plugins/dsh-engineering-ui/cordis.patch.yml +5 -0
  4. package/engineering/plugins/dsh-engineering-ui/icon.svg +14 -0
  5. package/engineering/plugins/dsh-engineering-ui/lib/client.js +8259 -0
  6. package/engineering/plugins/dsh-engineering-ui/lib/defense-sign.js +1552 -0
  7. package/engineering/plugins/dsh-engineering-ui/lib/index.js +5633 -0
  8. package/engineering/plugins/dsh-engineering-ui/lib/job-wait-guard.js +236 -0
  9. package/engineering/plugins/dsh-engineering-ui/lib/mem-guard.js +331 -0
  10. package/engineering/plugins/dsh-engineering-ui/lib/webots.js +114 -0
  11. package/engineering/plugins/dsh-engineering-ui/locale/en.json +6 -0
  12. package/engineering/plugins/dsh-engineering-ui/locale/zh.json +6 -0
  13. package/engineering/plugins/dsh-engineering-ui/preset-engineering.patch.yml +601 -0
  14. package/engineering/skills/assembly-orchestration/SKILL.md +651 -0
  15. package/engineering/skills/cad-workflow/SKILL.md +132 -0
  16. package/engineering/skills/mode-selection/SKILL.md +660 -0
  17. package/engineering/skills/physics-in-loop/SKILL.md +238 -0
  18. package/engineering/skills/physics-in-loop/references/feadocs.md +142 -0
  19. package/engineering/skills/solidworks-bridge/SKILL.md +445 -0
  20. package/engineering/skills/sw-design/SKILL.md +112 -0
  21. package/engineering/skills/sw-design/references/autocad-workflow.md +81 -0
  22. package/engineering/skills/sw-design/references/gbt-drafting.md +71 -0
  23. package/engineering/skills/sw-to-cad/SKILL.md +121 -0
  24. package/engineering/skills/webots-simulation/SKILL.md +58 -0
  25. package/engineering/tools/LICENSE.Auto-SolidWorks +21 -0
  26. package/engineering/tools/_plugin_paths.py +93 -0
  27. package/engineering/tools/_root.py +330 -0
  28. package/engineering/tools/_store.py +446 -0
  29. package/engineering/tools/ac_bridge.py +459 -0
  30. package/engineering/tools/ac_rules.json +21 -0
  31. package/engineering/tools/ac_validate.py +699 -0
  32. package/engineering/tools/ask_user.py +887 -0
  33. package/engineering/tools/build_arm1.py +46 -0
  34. package/engineering/tools/build_assembly.py +94 -0
  35. package/engineering/tools/build_base.py +57 -0
  36. package/engineering/tools/build_housing.py +70 -0
  37. package/engineering/tools/build_structural.py +43 -0
  38. package/engineering/tools/build_transmission.py +69 -0
  39. package/engineering/tools/cadx.lsp +128 -0
  40. package/engineering/tools/choice_contract.py +61 -0
  41. package/engineering/tools/conn_state.py +1671 -0
  42. package/engineering/tools/defense_gate.py +3374 -0
  43. package/engineering/tools/drawing_input.py +314 -0
  44. package/engineering/tools/env_check.py +467 -0
  45. package/engineering/tools/examples/build_axis.py +142 -0
  46. package/engineering/tools/examples/build_cup.py +56 -0
  47. package/engineering/tools/examples/build_plate.py +58 -0
  48. package/engineering/tools/examples/build_sphere.py +30 -0
  49. package/engineering/tools/examples/build_transition_duct.py +381 -0
  50. package/engineering/tools/gate_integrity.py +401 -0
  51. package/engineering/tools/mode2_flow.py +3589 -0
  52. package/engineering/tools/mode_checker.py +77 -0
  53. package/engineering/tools/mode_gate.py +8013 -0
  54. package/engineering/tools/part_registry.py +4266 -0
  55. package/engineering/tools/physics/__init__.py +50 -0
  56. package/engineering/tools/physics/_compat.py +12 -0
  57. package/engineering/tools/physics/design_state.py +142 -0
  58. package/engineering/tools/physics/domain_validator.py +751 -0
  59. package/engineering/tools/physics/fatigue.py +489 -0
  60. package/engineering/tools/physics/fea_solver.py +1163 -0
  61. package/engineering/tools/physics/feapy_solver.py +314 -0
  62. package/engineering/tools/physics/gb_materials.json +1228 -0
  63. package/engineering/tools/physics/geometry_gate.py +337 -0
  64. package/engineering/tools/physics/load_case.py +951 -0
  65. package/engineering/tools/physics/material_db.py +874 -0
  66. package/engineering/tools/physics/mesh_adapter.py +321 -0
  67. package/engineering/tools/physics/refine_rules.py +272 -0
  68. package/engineering/tools/physics/rules/gb_base_rules.json +377 -0
  69. package/engineering/tools/physics/rules/gb_drafting_rules.json +57 -0
  70. package/engineering/tools/physics/rules/gb_electrical_rules.json +25 -0
  71. package/engineering/tools/physics/rules/gb_fasteners_rules.json +73 -0
  72. package/engineering/tools/physics/rules/gb_housing_rules.json +41 -0
  73. package/engineering/tools/physics/rules/gb_manufacturing_rules.json +25 -0
  74. package/engineering/tools/physics/rules/gb_material_verify_rules.json +72 -0
  75. package/engineering/tools/physics/rules/gb_materials_rules.json +377 -0
  76. package/engineering/tools/physics/rules/gb_safety_rules.json +89 -0
  77. package/engineering/tools/physics/rules/gb_springs_rules.json +41 -0
  78. package/engineering/tools/physics/rules/gb_standards_registry.json +1646 -0
  79. package/engineering/tools/physics/rules/gb_structural_rules.json +201 -0
  80. package/engineering/tools/physics/rules/gb_surface_rules.json +25 -0
  81. package/engineering/tools/physics/rules/gb_transmission_rules.json +233 -0
  82. package/engineering/tools/physics/rules/housing_rules.json +48 -0
  83. package/engineering/tools/physics/rules/manifest.json +54 -0
  84. package/engineering/tools/physics/rules/mold_rules.json +48 -0
  85. package/engineering/tools/physics/rules/structural_rules.json +56 -0
  86. package/engineering/tools/physics/rules/transmission_rules.json +48 -0
  87. package/engineering/tools/physics/simulation_report.py +829 -0
  88. package/engineering/tools/physics_bridge.py +2685 -0
  89. package/engineering/tools/profile-manifest.ps1 +72 -0
  90. package/engineering/tools/run_regression.py +109 -0
  91. package/engineering/tools/settings.py +419 -0
  92. package/engineering/tools/solidworks-modeling.md +519 -0
  93. package/engineering/tools/sw_bridge.py +8215 -0
  94. package/engineering/tools/sw_guard.py +1066 -0
  95. package/engineering/tools/sw_surface.py +87 -0
  96. package/engineering/tools/swapi.py +14766 -0
  97. package/engineering/tools/verify_sync.py +218 -0
  98. package/engineering/tools/visual_check.py +2589 -0
  99. package/engineering/tools/webots_env.py +121 -0
  100. package/engineering/tools/webots_mcp/__init__.py +3 -0
  101. package/engineering/tools/webots_mcp/__main__.py +3 -0
  102. package/engineering/tools/webots_mcp/assembly.py +391 -0
  103. package/engineering/tools/webots_mcp/bootstrap.py +55 -0
  104. package/engineering/tools/webots_mcp/controller_worker.py +124 -0
  105. package/engineering/tools/webots_mcp/evidence.py +288 -0
  106. package/engineering/tools/webots_mcp/fake_worker.py +69 -0
  107. package/engineering/tools/webots_mcp/fault_resources.py +121 -0
  108. package/engineering/tools/webots_mcp/host.py +45 -0
  109. package/engineering/tools/webots_mcp/ledger.py +73 -0
  110. package/engineering/tools/webots_mcp/owned_job.py +70 -0
  111. package/engineering/tools/webots_mcp/physical_gate.py +187 -0
  112. package/engineering/tools/webots_mcp/physics.py +152 -0
  113. package/engineering/tools/webots_mcp/pipeline.py +108 -0
  114. package/engineering/tools/webots_mcp/recipes.py +129 -0
  115. package/engineering/tools/webots_mcp/repair.py +132 -0
  116. package/engineering/tools/webots_mcp/repair_trial.py +375 -0
  117. package/engineering/tools/webots_mcp/runtime.py +311 -0
  118. package/engineering/tools/webots_mcp/server.py +217 -0
  119. package/engineering/tools/webots_mcp/sw_export.py +509 -0
  120. package/engineering/tools/webots_mcp/transport.py +156 -0
  121. package/engineering/tools/workflow_gate.py +6134 -0
  122. package/package.json +64 -4
@@ -0,0 +1,311 @@
1
+ """Single-instance scheduler. All ledger changes occur on the MCP event-loop thread."""
2
+ import asyncio
3
+ import json
4
+ from datetime import datetime, timedelta, timezone
5
+ import sqlite3
6
+ import time
7
+ from uuid import uuid4
8
+
9
+ from .ledger import Ledger, now
10
+ from .fault_resources import budget_check
11
+
12
+
13
+ class Runtime:
14
+ def __init__(self,state,root,config,backend=None):
15
+ if backend is None:
16
+ from .transport import Transport
17
+ backend = Transport
18
+ self.backend = backend
19
+ self.state,self.root,self.config = state,root,config
20
+ if not {"process_count","wall_time_s","private_bytes"} <= set(config["limits"]):
21
+ raise ValueError("Explicit process, wall and private memory ceilings required")
22
+ budget_check({},config["limits"])
23
+ self.ledger = Ledger(state)
24
+ self.owner = self.live = self.monitor = None
25
+ self.jobs = {}
26
+ self.controls = {}
27
+ self.serial = asyncio.Lock()
28
+ self.cleanup = asyncio.Lock()
29
+ self.progress = dict(completed_steps=None,sim_time=None)
30
+ self.observed = None
31
+ self.sample_at = None
32
+ self.next_projection = 0.
33
+ self.poisoned = False
34
+ self.closing = False
35
+ self.last_error = None
36
+ try:
37
+ self.project("NEW")
38
+ except BaseException:
39
+ self.ledger.close()
40
+ raise
41
+
42
+ def project(self,state):
43
+ from .evidence import _atomic_write
44
+ owner = self.owner or (None,None,None,None)
45
+ stamp = datetime.now(timezone.utc)
46
+ ready = bool(self.live and self.live.connected and self.backend.provenance=="real-webots" and not self.poisoned)
47
+ snapshot = dict(schema_version=1,task_id=owner[0],room_id=owner[1],run_id=owner[2],
48
+ session_generation=owner[3],job_state=state,core_ready=ready,stale=not ready,
49
+ snapshot_at=stamp.isoformat(),valid_until=(stamp+timedelta(seconds=2)).isoformat(),
50
+ provenance=self.backend.provenance,diagnostic=self.last_error,**self.progress)
51
+ _atomic_write(self.state/"webots_state.json",json.dumps(snapshot,allow_nan=False).encode())
52
+
53
+ def fail(self,result,code,hint):
54
+ return dict(result,ok=False,error_code=code,hint=hint)
55
+
56
+ def owned(self,row,a):
57
+ return row and (row["task"],row["room"]) == (a["task_id"],a["room_id"])
58
+
59
+ def snapshot(self,row,request):
60
+ result = dict(json.loads(row["result"]),request_id=request)
61
+ live = (self.owner == (row["task"],row["room"],row["run"],row["gen"])
62
+ and self.live and self.live.connected and not self.poisoned)
63
+ result.update(core_ready=bool(live and self.backend.provenance == "real-webots"),
64
+ stale=not bool(live))
65
+ if self.poisoned and row["phase"] == "dispatched":
66
+ result.update(ok=False,error_code="OUTCOME_UNKNOWN",execution_state="unknown",job_state="UNKNOWN",
67
+ core_ready=False,stale=True,hint="Result commit unavailable. Do not replay.")
68
+ return result
69
+
70
+ async def dispatch(self,name,a,r,input_path=None):
71
+ r.update(provenance=self.backend.provenance,hint="Query sim_job by job/run/generation. Unknown actions must not be replayed.")
72
+ try:
73
+ return await self._dispatch(name,a,r,input_path)
74
+ except (sqlite3.Error,OSError) as err:
75
+ self.poisoned = True
76
+ return self.fail(r,"LEDGER_IO_ERROR",str(err)[:200])
77
+
78
+ async def _dispatch(self,name,a,r,input_path):
79
+ scope = (a["task_id"],a["room_id"])
80
+ if name == "sim_status":
81
+ visible = self.owner and self.owner[:2] == scope
82
+ ready = bool(visible and self.live and self.live.connected and not self.poisoned)
83
+ return dict(r,ok=True,core_ready=ready and self.backend.provenance == "real-webots",job_state="READY" if ready else "NEW",
84
+ session_generation=self.owner[3] if visible else None,
85
+ run_id=self.owner[2] if visible else None,stale=not ready,
86
+ snapshot_at=(self.sample_at or self.observed) if visible else r["snapshot_at"],
87
+ diagnostic=self.last_error if visible else None, **(self.progress if visible else {}))
88
+ if name == "sim_job":
89
+ row = self.ledger.lookup("job",a["job_id"])
90
+ if not self.owned(row,a) or (row["run"],row["gen"]) != (a["run_id"],a["session_generation"]):
91
+ return self.fail(r,"JOB_NOT_FOUND","No job in this scope")
92
+ return self.snapshot(row,a["request_id"])
93
+ op = a.get("operation_id")
94
+ digest = self.ledger.digest(name,a)
95
+ if op and (row := self.ledger.lookup("op",op)):
96
+ if not self.owned(row,a) or row["digest"] != digest:
97
+ return self.fail(r,"OPERATION_CONFLICT","Operation ID already bound")
98
+ return self.snapshot(row,a["request_id"])
99
+ if input_path is not None:
100
+ try:
101
+ for key,suffix in (("world_path",".wbt"),("urdf_path",".urdf")):
102
+ if key in a:
103
+ input_path(a[key],suffix)
104
+ except (ValueError,OSError) as error:
105
+ return self.fail(r,"INVALID_ARGUMENT",str(error)[:256])
106
+ if self.poisoned:
107
+ return self.fail(r,"LEDGER_IO_ERROR","Persistence failed; restart and reconcile")
108
+ if name == "sim_convert":
109
+ return self.fail(r,"UNSUPPORTED","Converter requires calibrated binding")
110
+ if name == "sim_launch":
111
+ if self.owner or self.closing:
112
+ return self.fail(r,"INSTANCE_BUSY","One active session per state root")
113
+ if a["world_path"] not in self.config["worlds"]:
114
+ return self.fail(r,"UNBOUND_WORLD","Explicit approved world hash and robot required")
115
+ run,gen = "run-"+uuid4().hex,1
116
+ else:
117
+ run,gen = a["run_id"],a["session_generation"]
118
+ if self.owner != (*scope,run,gen):
119
+ return self.fail(r,"STALE_SESSION","Run/generation is not live in this scope")
120
+ if name in ("sim_field","sim_scene","sim_devices"):
121
+ if not self.live or not self.live.connected or self.serial.locked() or self.jobs:
122
+ return self.fail(r,"WORKER_BUSY","Read only in an idle verified session")
123
+ async with self.serial:
124
+ try:
125
+ data = await asyncio.wait_for(self.live.command(name,a,lambda *_:None),5)
126
+ return dict(r,**data,ok=True,execution_state="completed",stale=False)
127
+ except Exception as err:
128
+ await self.invalidate()
129
+ return self.fail(r,"WORKER_DISCONNECTED",str(err))
130
+ if name == "sim_cancel":
131
+ target = self.ledger.lookup("job",a["job_id"])
132
+ if not self.owned(target,a) or (target["run"],target["gen"]) != (run,gen):
133
+ return self.fail(r,"JOB_NOT_FOUND","Target outside active session")
134
+ if (len(self.jobs)>=16 and name!="sim_cancel") or (len(self.controls)>=16 and name=="sim_cancel"):
135
+ return self.fail(r,"QUEUE_FULL","Session queue is bounded")
136
+ r.update(run_id=run,session_generation=gen,job_id="job-"+uuid4().hex,ok=True,
137
+ job_state="QUEUED",execution_state="not_started",stale=False)
138
+ self.ledger.accept(a,digest,r)
139
+ if name == "sim_launch":
140
+ self.last_error = None
141
+ self.owner = (*scope,run,gen)
142
+ self.world_path = a["world_path"]
143
+ self.progress = dict(completed_steps=None,sim_time=None)
144
+ self.observed = now()
145
+ self.sample_at = None
146
+ if name == "sim_cancel":
147
+ self.ledger.finish(op,"dispatched",job_state="RUNNING",execution_state="running")
148
+ task = asyncio.create_task(self.cancel_target(op,target,scope,run,gen))
149
+ self.controls[op] = task
150
+ task.add_done_callback(lambda _:self.controls.pop(op,None))
151
+ return await asyncio.shield(task)
152
+ task = asyncio.create_task(self.execute(name,a,op,self.owner))
153
+ self.jobs[op] = task
154
+ task.add_done_callback(lambda _:self.jobs.pop(op,None))
155
+ return r
156
+
157
+ async def cancel_target(self,op,target,scope,run,gen):
158
+ try:
159
+ job = self.jobs.get(target["op"])
160
+ if job:
161
+ job.cancel()
162
+ await asyncio.gather(job,return_exceptions=True)
163
+ # Task may have been cancelled before its coroutine started.
164
+ if self.ledger.lookup("op",target["op"])["phase"] == "accepted":
165
+ if self.live is None and self.owner == (*scope,run,gen):
166
+ await self.invalidate()
167
+ self.ledger.finish(target["op"],"rejected",ok=False,error_code="CANCELLED_BEFORE_DISPATCH",
168
+ job_state="CANCELLED",execution_state="not_started")
169
+ return self.ledger.finish(op,"done",job_state="COMPLETED",execution_state="completed",
170
+ data={"target_job":target["job"],"rollback_confirmed":False})
171
+ except (OSError,sqlite3.Error):
172
+ self.poisoned = True
173
+ return self.snapshot(self.ledger.lookup("op",op),None)
174
+
175
+ def observe(self,name,a,op,owner,out):
176
+ from .evidence import EvidenceStore
177
+ store = EvidenceStore(self.state,task_id=owner[0],room_id=owner[1])
178
+ identity = self.live.identity
179
+ data = out["data"]
180
+ generation = self.owner[3]
181
+ observation = dict(run_id=owner[2],generation=generation,world_sha256=identity["world_sha256"],
182
+ robot=identity["robot"],sim_time=out["sim_time"],step=out["completed_steps"],action=name,
183
+ operation_id=op,targets=[{"def":data["node_def"],"node_id":data["node_id"]}] if "node_def" in data else [],
184
+ measurement=data,model_sha256=identity["model_sha256"])
185
+ producer = dict(name="webots_mcp.controller_worker",version="A04",source_revision=None)
186
+ raw = json.dumps(observation,ensure_ascii=False,sort_keys=True,allow_nan=False).encode()
187
+ items = [store.save_snapshot(owner[2],"obs-"+op+".json",raw,kind="observation",
188
+ provenance="real-webots",producer=producer)]
189
+ if name == "sim_screenshot":
190
+ filename = "capture-"+op+".jpg"
191
+ image = (self.live.dir/filename).read_bytes()
192
+ import hashlib
193
+ if hashlib.sha256(image).hexdigest() != data["sha256"]:
194
+ raise ValueError("Screenshot bytes changed after capture")
195
+ items.append(store.save_snapshot(owner[2],filename,image,kind="screenshot",
196
+ provenance="real-webots",producer=producer))
197
+ if name == "sim_launch":
198
+ report = store.new_report("experiment_report",owner[2],provenance="real-webots",
199
+ data={"runtime_identity":identity,"model":{"sha256":identity["model_sha256"]}},
200
+ evidence=items,missing_information=["paired_trace","calibration","collision_coverage","physical_gate"])
201
+ store.write_report(report)
202
+ store.append_log(owner[2],json.dumps(dict(action=name,operation_id=op,generation=generation,
203
+ completed_steps=out["completed_steps"],sim_time=out["sim_time"],evidence=items),allow_nan=False)+"\n")
204
+ return items
205
+
206
+ async def execute(self,name,a,op,owner):
207
+ dispatched = False
208
+ try:
209
+ async with self.serial:
210
+ if self.owner != owner or self.closing or self.poisoned:
211
+ raise RuntimeError("Session invalidated before dispatch")
212
+ self.ledger.finish(op,"dispatched",job_state="RUNNING",execution_state="running",**self.progress)
213
+ self.project("STARTING" if name in ("sim_launch","sim_reset") else "RUNNING")
214
+ dispatched = True
215
+ def progress(n,t):
216
+ self.progress = dict(completed_steps=n,sim_time=t)
217
+ self.observed = now()
218
+ self.ledger.finish(op,"dispatched",**self.progress)
219
+ if name in ("sim_launch","sim_reset"):
220
+ generation = owner[3]
221
+ if name == "sim_reset":
222
+ if self.monitor:
223
+ self.monitor.cancel()
224
+ await self.live.close()
225
+ generation += 1
226
+ self.owner = (*owner[:3],generation)
227
+ if self.backend.provenance == "real-webots":
228
+ from .evidence import EvidenceStore
229
+ store = EvidenceStore(self.state,task_id=owner[0],room_id=owner[1])
230
+ store._run(owner[2],claim=True)
231
+ self.live = self.backend(self.state,self.root,self.config,owner[2],generation)
232
+ self.monitor = asyncio.create_task(self.watch())
233
+ call = self.live.launch(dict(a,world_path=self.world_path))
234
+ else:
235
+ call = self.live.command(name,a,progress)
236
+ out = await asyncio.wait_for(call,30)
237
+ self.progress = {k:out[k] for k in ("completed_steps","sim_time")}
238
+ self.observed = now()
239
+ if name == "sim_reset":
240
+ out["data"]["new_generation"] = generation
241
+ if self.backend.provenance == "real-webots":
242
+ out["evidence"] = self.observe(name,a,op,owner,out)
243
+ if name == "sim_shutdown":
244
+ await self.invalidate()
245
+ self.project("READY" if self.live else "COMPLETED")
246
+ self.ledger.finish(op,"done",job_state="COMPLETED",execution_state="completed",
247
+ core_ready=bool(self.live and self.live.connected and self.backend.provenance == "real-webots"),**out)
248
+ except (Exception,asyncio.CancelledError) as err:
249
+ try:
250
+ self.last_error = self.last_error or str(err) or "Cancellation requested"
251
+ if dispatched or (name == "sim_launch" and self.owner == owner):
252
+ await self.invalidate()
253
+ self.ledger.finish(op,"unknown" if dispatched else "rejected",ok=False,
254
+ error_code="OUTCOME_UNKNOWN" if dispatched else "NOT_DISPATCHED",
255
+ execution_state="unknown" if dispatched else "not_started",
256
+ job_state="UNKNOWN" if dispatched else "FAILED",stale=True,
257
+ hint=(self.last_error or str(err) or "Cancellation; no rollback proof")[:256])
258
+ except Exception as failure:
259
+ self.last_error = str(failure)
260
+ self.poisoned = True
261
+ if isinstance(err,sqlite3.Error):
262
+ self.poisoned = True
263
+
264
+ async def watch(self):
265
+ try:
266
+ while self.live:
267
+ await asyncio.sleep(.25)
268
+ if not self.live:
269
+ return
270
+ self.live.sample()
271
+ self.sample_at = now()
272
+ if self.live.connected and time.monotonic() >= self.next_projection:
273
+ self.next_projection = time.monotonic()+1
274
+ self.project("RUNNING" if self.serial.locked() else "READY")
275
+ except Exception as err:
276
+ self.last_error = str(err)[:256]
277
+ running = [op for op in self.jobs if self.ledger.lookup("op",op)["phase"] == "dispatched"]
278
+ for op in running:
279
+ self.jobs[op].cancel()
280
+ if not running:
281
+ await self.invalidate()
282
+
283
+ async def invalidate(self):
284
+ async with self.cleanup:
285
+ await self._invalidate()
286
+
287
+ async def _invalidate(self):
288
+ self.closing = True
289
+ self.owner = None
290
+ if self.monitor and self.monitor is not asyncio.current_task():
291
+ self.monitor.cancel()
292
+ if self.live:
293
+ try:
294
+ await self.live.close()
295
+ except Exception:
296
+ self.poisoned = True
297
+ raise
298
+ self.live = self.owner = None
299
+ self.project("UNKNOWN" if self.last_error else "COMPLETED")
300
+ self.closing = False
301
+ self.observed = now()
302
+
303
+ async def close(self):
304
+ tasks = list(self.jobs.values())
305
+ for task in tasks:
306
+ task.cancel()
307
+ await asyncio.gather(*tasks,*list(self.controls.values()),return_exceptions=True)
308
+ try:
309
+ await self.invalidate()
310
+ finally:
311
+ self.ledger.close()
@@ -0,0 +1,217 @@
1
+ """Official MCP SDK stdio transport with explicit unavailable/fake execution."""
2
+ import argparse
3
+ import asyncio
4
+ from datetime import datetime, timezone
5
+ import json
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+ import re
10
+ import sys
11
+
12
+ from jsonschema import Draft202012Validator
13
+ from mcp import types
14
+ from mcp.server import Server
15
+ from mcp.server.stdio import stdio_server
16
+
17
+ from . import __version__
18
+ from .fake_worker import FakeWorker
19
+
20
+ INSTRUCTIONS = "Bounded Webots runtime and evidence inspection. Execution requires an explicitly pinned local runtime. Native CAD export and repair require calibration. Fake fixtures never prove readiness or physics. Unknown outcomes must not be replayed."
21
+ CAPABILITIES = ["stored_reports", "bounded_evidence", "attribution", "pinned_runtime", "durable_operations"]
22
+ BLOCKED_NATIVE = ["native_mesh_frame", "complete_global_mate_state", "native_identity_calibration",
23
+ "converter_physical_precision", "calibrated_collision_feedback", "authorized_native_repair"]
24
+ ID = {"type": "string", "pattern": r"^[A-Za-z0-9][A-Za-z0-9_-]{0,95}(?![\s\S])"}
25
+ TEXT = {"type": "string", "minLength": 1, "maxLength": 256}
26
+ CONTEXT = {key: ID for key in ("task_id", "room_id", "request_id")}
27
+ SESSION = {"run_id": ID, "session_generation": {"type": "integer", "minimum": 1}}
28
+ MUTATIONS = {"sim_launch", "sim_shutdown", "sim_step", "sim_reset", "sim_screenshot",
29
+ "sim_contacts", "sim_convert", "sim_motor", "sim_sensor", "sim_cancel"}
30
+
31
+
32
+ def tool(name, description, properties=None, *, scoped=True, optional=()):
33
+ fields = {**CONTEXT, **(SESSION if scoped else {}), **(properties or {})}
34
+ if name in MUTATIONS:
35
+ fields["operation_id"] = ID
36
+ return types.Tool(name=name, description=description, inputSchema={
37
+ "type": "object", "properties": fields,
38
+ "required": [key for key in fields if key not in optional], "additionalProperties": False})
39
+
40
+
41
+ TOOLS = [
42
+ tool("sim_status", "Cached availability; never starts Webots.", scoped=False),
43
+ tool("sim_launch", "Submit a pinned world job when a runtime is configured.", {"world_path": TEXT}, scoped=False),
44
+ tool("sim_shutdown", "Request owned-session shutdown and confirm exit."),
45
+ tool("sim_step", "Submit bounded steps.", {"steps": {"type": "integer", "minimum": 1, "maximum": 1000}}),
46
+ tool("sim_reset", "Cold-reset a pinned world into a new generation."),
47
+ tool("sim_scene", "Read scene snapshot."),
48
+ tool("sim_field", "Read one node field.", {"node": TEXT, "field": TEXT}),
49
+ tool("sim_screenshot", "Request an on-demand view."),
50
+ tool("sim_contacts", "Request contact observation; no physical verdict.", {"node": TEXT}),
51
+ tool("sim_convert", "Conversion requires a calibrated converter binding.", {"urdf_path": TEXT}, scoped=False),
52
+ tool("sim_devices", "Discover devices of the owned robot."),
53
+ tool("sim_motor", "Submit one motor command.", {"device": TEXT, "velocity": {"type": "number"},
54
+ "position": {"type": "number"}}, optional=("position",)),
55
+ tool("sim_sensor", "Request one sensor sample.", {"device": TEXT}),
56
+ tool("sim_job", "Read job snapshot; unknown must not be replayed.", {"job_id": ID}),
57
+ tool("sim_cancel", "Request cancellation; acknowledgement is not rollback.", {"job_id": ID}),
58
+ tool("sim_report", "Read an owned stored report.", {"run_id": ID, "report_type": {
59
+ "enum": ["model_manifest", "conversion_report", "experiment_report"]}}, scoped=False),
60
+ tool("sim_attribute", "Inspect one owned issue; retain all candidates and evidence levels. Never writes CAD.",
61
+ {"run_id": ID, "issue_id": ID}, scoped=False),
62
+ tool("sim_evidence", "Read an owned bounded log page.", {"run_id": ID, "name": ID,
63
+ "offset": {"type": "integer", "minimum": 0},
64
+ "limit": {"type": "integer", "minimum": 4, "maximum": 16384},
65
+ "expected_sha256": {"type": "string", "pattern": r"^[a-f0-9]{64}(?![\s\S])"}},
66
+ scoped=False, optional=("name", "offset", "limit", "expected_sha256")),
67
+ ]
68
+ TOOL_INDEX = {item.name: item for item in TOOLS}
69
+
70
+
71
+ class PeripheralServer:
72
+ def __init__(self, state_dir, *, input_root, fake_worker=None, runtime_config=None):
73
+ self.state_dir = Path(state_dir).resolve(strict=True)
74
+ self.input_root = Path(input_root).resolve(strict=True)
75
+ if not self.state_dir.is_dir() or not self.input_root.is_dir():
76
+ raise ValueError("State and input roots must be existing directories")
77
+ self.runtime = None
78
+ if runtime_config is not None:
79
+ if fake_worker:
80
+ raise ValueError("Select exactly one worker backend")
81
+ from .runtime import Runtime
82
+ self.runtime = Runtime(self.state_dir,self.input_root,runtime_config)
83
+ self.worker = FakeWorker(fake_worker) if fake_worker else None
84
+
85
+ def envelope(self, args):
86
+ result = {key: args.get(key) for key in (
87
+ "run_id", "job_id", "request_id", "operation_id", "session_generation", "task_id", "room_id")}
88
+ for key, value in result.items():
89
+ valid = (type(value) is int and value >= 1) if key == "session_generation" else (
90
+ isinstance(value, str) and re.fullmatch(ID["pattern"], value))
91
+ if not valid:
92
+ result[key] = None
93
+ result.update(schema_version=1, ok=False, error_code=None, execution_state="not_started",
94
+ sim_time=None, completed_steps=None, evidence=[], core_ready=False,
95
+ provenance="fake" if self.worker else "unavailable", job_state="NEW",
96
+ snapshot_at=datetime.now(timezone.utc).isoformat(), stale=True,
97
+ capabilities=CAPABILITIES, blocked_native=BLOCKED_NATIVE,
98
+ hint="Runtime is opt-in; native CAD export and repair await measured calibration.")
99
+ return result
100
+
101
+ def input_path(self, value, suffix):
102
+ relative = Path(value)
103
+ if relative.is_absolute() or ".." in relative.parts or ":" in value or "\\" in value:
104
+ raise ValueError("Input must be a relative path inside input_root")
105
+ path = (self.input_root / relative).resolve(strict=True)
106
+ if not path.is_relative_to(self.input_root) or not path.is_file() or path.suffix.lower() != suffix:
107
+ raise ValueError("Input path escapes root or has an unsupported file type")
108
+ return path
109
+
110
+ async def dispatch(self, name, args):
111
+ result = self.envelope(args if isinstance(args, dict) else {})
112
+ if name not in TOOL_INDEX:
113
+ result["error_code"] = "UNKNOWN_TOOL"
114
+ return result
115
+ try:
116
+ json.dumps(args, allow_nan=False)
117
+ errors = list(Draft202012Validator(TOOL_INDEX[name].inputSchema).iter_errors(args))
118
+ if errors:
119
+ raise ValueError(errors[0].message)
120
+ for key, suffix in (("world_path", ".wbt"), ("urdf_path", ".urdf")):
121
+ if key in args and not self.runtime:
122
+ self.input_path(args[key], suffix)
123
+ except (ValueError, TypeError, OSError) as error:
124
+ result.update(error_code="INVALID_ARGUMENT", hint=str(error)[:256])
125
+ return result
126
+ if name in ("sim_report", "sim_evidence", "sim_attribute"):
127
+ try:
128
+ from .evidence import EvidenceStore
129
+ store = EvidenceStore(self.state_dir, task_id=args["task_id"], room_id=args["room_id"])
130
+ if name == "sim_report":
131
+ data = store.read_report(args["run_id"], args["report_type"])
132
+ elif name == "sim_attribute":
133
+ from .repair import attribute
134
+ data = attribute(store, args["run_id"], args["issue_id"])
135
+ else:
136
+ page_args = {key: args[key] for key in ("name", "offset", "limit", "expected_sha256") if key in args}
137
+ data = store.read_log(args["run_id"], **page_args)
138
+ result.update(ok=True, execution_state="completed", data=data)
139
+ except ImportError:
140
+ result["error_code"] = "EVIDENCE_UNAVAILABLE"
141
+ except PermissionError:
142
+ result["error_code"] = "OWNERSHIP_MISMATCH"
143
+ except FileNotFoundError:
144
+ result["error_code"] = "EVIDENCE_NOT_FOUND"
145
+ except (ValueError, TypeError, KeyError, AttributeError) as error:
146
+ result.update(error_code="EVIDENCE_INVALID", hint=str(error)[:256])
147
+ except OSError as error:
148
+ result.update(error_code="EVIDENCE_IO_ERROR", hint=str(error)[:256])
149
+ return result
150
+ if name == "sim_convert" and not self.worker:
151
+ result.update(error_code="CALIBRATION_REQUIRED",
152
+ hint="Native export/conversion is blocked: measured mesh frame, complete mates and converter precision are unverified.")
153
+ return result
154
+ if self.runtime:
155
+ return await self.runtime.dispatch(name,args,result,self.input_path)
156
+ if self.worker:
157
+ return self.worker.dispatch(name, args, result)
158
+ if name == "sim_status":
159
+ result.update(ok=True, unsupported=["unconfigured_runtime", "native_export", "native_repair"],
160
+ state_dir=str(self.state_dir))
161
+ else:
162
+ result["error_code"] = "CORE_NOT_IMPLEMENTED"
163
+ return result
164
+
165
+ def sdk_server(self):
166
+ server = Server("webots", version=__version__, instructions=INSTRUCTIONS)
167
+
168
+ @server.list_tools()
169
+ async def list_tools():
170
+ return TOOLS
171
+
172
+ @server.call_tool(validate_input=False)
173
+ async def call_tool(name, arguments):
174
+ result = await self.dispatch(name, arguments)
175
+ return types.CallToolResult(
176
+ content=[types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False, allow_nan=False))],
177
+ structuredContent=result, isError=not result["ok"])
178
+
179
+ return server
180
+
181
+
182
+ async def serve(peripheral):
183
+ server = peripheral.sdk_server()
184
+ async with stdio_server() as (read_stream, write_stream):
185
+ await server.run(read_stream, write_stream, server.create_initialization_options())
186
+
187
+
188
+ def main():
189
+ parser = argparse.ArgumentParser(description=__doc__)
190
+ parser.add_argument("--input-root", required=True)
191
+ parser.add_argument("--fake-worker", choices=("complete", "error", "timeout", "unknown"))
192
+ parser.add_argument("--runtime-config", type=Path, help="Trusted A04 pinned local runtime configuration")
193
+ args = parser.parse_args()
194
+ logging.basicConfig(stream=sys.stderr, level=logging.INFO)
195
+ declared = os.environ.get("DSH_STATE_DIR")
196
+ if not declared or not Path(declared).is_dir():
197
+ parser.error("DSH_STATE_DIR must explicitly name an existing isolated state directory")
198
+ from _store import state_dir
199
+ resolved = Path(state_dir()).resolve(strict=True)
200
+ if resolved != Path(declared).resolve(strict=True):
201
+ parser.error("Resolved state_dir does not match DSH_STATE_DIR")
202
+ logging.info("webots state_dir=%s core_ready=false backend=%s", resolved,
203
+ "configured" if args.runtime_config else ("fake" if args.fake_worker else "unavailable"))
204
+ config = json.loads(args.runtime_config.read_text(encoding="utf-8")) if args.runtime_config else None
205
+ peripheral = PeripheralServer(resolved, input_root=args.input_root, fake_worker=args.fake_worker,runtime_config=config)
206
+ async def run():
207
+ try:
208
+ await serve(peripheral)
209
+ finally:
210
+ if peripheral.runtime:
211
+ await peripheral.runtime.close()
212
+ asyncio.run(run())
213
+
214
+
215
+ if __name__ == "__main__":
216
+ main()
217
+