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.
- package/LICENSE +21 -0
- package/README.md +772 -3
- package/engineering/plugins/dsh-engineering-ui/cordis.patch.yml +5 -0
- package/engineering/plugins/dsh-engineering-ui/icon.svg +14 -0
- package/engineering/plugins/dsh-engineering-ui/lib/client.js +8259 -0
- package/engineering/plugins/dsh-engineering-ui/lib/defense-sign.js +1552 -0
- package/engineering/plugins/dsh-engineering-ui/lib/index.js +5633 -0
- package/engineering/plugins/dsh-engineering-ui/lib/job-wait-guard.js +236 -0
- package/engineering/plugins/dsh-engineering-ui/lib/mem-guard.js +331 -0
- package/engineering/plugins/dsh-engineering-ui/lib/webots.js +114 -0
- package/engineering/plugins/dsh-engineering-ui/locale/en.json +6 -0
- package/engineering/plugins/dsh-engineering-ui/locale/zh.json +6 -0
- package/engineering/plugins/dsh-engineering-ui/preset-engineering.patch.yml +601 -0
- package/engineering/skills/assembly-orchestration/SKILL.md +651 -0
- package/engineering/skills/cad-workflow/SKILL.md +132 -0
- package/engineering/skills/mode-selection/SKILL.md +660 -0
- package/engineering/skills/physics-in-loop/SKILL.md +238 -0
- package/engineering/skills/physics-in-loop/references/feadocs.md +142 -0
- package/engineering/skills/solidworks-bridge/SKILL.md +445 -0
- package/engineering/skills/sw-design/SKILL.md +112 -0
- package/engineering/skills/sw-design/references/autocad-workflow.md +81 -0
- package/engineering/skills/sw-design/references/gbt-drafting.md +71 -0
- package/engineering/skills/sw-to-cad/SKILL.md +121 -0
- package/engineering/skills/webots-simulation/SKILL.md +58 -0
- package/engineering/tools/LICENSE.Auto-SolidWorks +21 -0
- package/engineering/tools/_plugin_paths.py +93 -0
- package/engineering/tools/_root.py +330 -0
- package/engineering/tools/_store.py +446 -0
- package/engineering/tools/ac_bridge.py +459 -0
- package/engineering/tools/ac_rules.json +21 -0
- package/engineering/tools/ac_validate.py +699 -0
- package/engineering/tools/ask_user.py +887 -0
- package/engineering/tools/build_arm1.py +46 -0
- package/engineering/tools/build_assembly.py +94 -0
- package/engineering/tools/build_base.py +57 -0
- package/engineering/tools/build_housing.py +70 -0
- package/engineering/tools/build_structural.py +43 -0
- package/engineering/tools/build_transmission.py +69 -0
- package/engineering/tools/cadx.lsp +128 -0
- package/engineering/tools/choice_contract.py +61 -0
- package/engineering/tools/conn_state.py +1671 -0
- package/engineering/tools/defense_gate.py +3374 -0
- package/engineering/tools/drawing_input.py +314 -0
- package/engineering/tools/env_check.py +467 -0
- package/engineering/tools/examples/build_axis.py +142 -0
- package/engineering/tools/examples/build_cup.py +56 -0
- package/engineering/tools/examples/build_plate.py +58 -0
- package/engineering/tools/examples/build_sphere.py +30 -0
- package/engineering/tools/examples/build_transition_duct.py +381 -0
- package/engineering/tools/gate_integrity.py +401 -0
- package/engineering/tools/mode2_flow.py +3589 -0
- package/engineering/tools/mode_checker.py +77 -0
- package/engineering/tools/mode_gate.py +8013 -0
- package/engineering/tools/part_registry.py +4266 -0
- package/engineering/tools/physics/__init__.py +50 -0
- package/engineering/tools/physics/_compat.py +12 -0
- package/engineering/tools/physics/design_state.py +142 -0
- package/engineering/tools/physics/domain_validator.py +751 -0
- package/engineering/tools/physics/fatigue.py +489 -0
- package/engineering/tools/physics/fea_solver.py +1163 -0
- package/engineering/tools/physics/feapy_solver.py +314 -0
- package/engineering/tools/physics/gb_materials.json +1228 -0
- package/engineering/tools/physics/geometry_gate.py +337 -0
- package/engineering/tools/physics/load_case.py +951 -0
- package/engineering/tools/physics/material_db.py +874 -0
- package/engineering/tools/physics/mesh_adapter.py +321 -0
- package/engineering/tools/physics/refine_rules.py +272 -0
- package/engineering/tools/physics/rules/gb_base_rules.json +377 -0
- package/engineering/tools/physics/rules/gb_drafting_rules.json +57 -0
- package/engineering/tools/physics/rules/gb_electrical_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_fasteners_rules.json +73 -0
- package/engineering/tools/physics/rules/gb_housing_rules.json +41 -0
- package/engineering/tools/physics/rules/gb_manufacturing_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_material_verify_rules.json +72 -0
- package/engineering/tools/physics/rules/gb_materials_rules.json +377 -0
- package/engineering/tools/physics/rules/gb_safety_rules.json +89 -0
- package/engineering/tools/physics/rules/gb_springs_rules.json +41 -0
- package/engineering/tools/physics/rules/gb_standards_registry.json +1646 -0
- package/engineering/tools/physics/rules/gb_structural_rules.json +201 -0
- package/engineering/tools/physics/rules/gb_surface_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_transmission_rules.json +233 -0
- package/engineering/tools/physics/rules/housing_rules.json +48 -0
- package/engineering/tools/physics/rules/manifest.json +54 -0
- package/engineering/tools/physics/rules/mold_rules.json +48 -0
- package/engineering/tools/physics/rules/structural_rules.json +56 -0
- package/engineering/tools/physics/rules/transmission_rules.json +48 -0
- package/engineering/tools/physics/simulation_report.py +829 -0
- package/engineering/tools/physics_bridge.py +2685 -0
- package/engineering/tools/profile-manifest.ps1 +72 -0
- package/engineering/tools/run_regression.py +109 -0
- package/engineering/tools/settings.py +419 -0
- package/engineering/tools/solidworks-modeling.md +519 -0
- package/engineering/tools/sw_bridge.py +8215 -0
- package/engineering/tools/sw_guard.py +1066 -0
- package/engineering/tools/sw_surface.py +87 -0
- package/engineering/tools/swapi.py +14766 -0
- package/engineering/tools/verify_sync.py +218 -0
- package/engineering/tools/visual_check.py +2589 -0
- package/engineering/tools/webots_env.py +121 -0
- package/engineering/tools/webots_mcp/__init__.py +3 -0
- package/engineering/tools/webots_mcp/__main__.py +3 -0
- package/engineering/tools/webots_mcp/assembly.py +391 -0
- package/engineering/tools/webots_mcp/bootstrap.py +55 -0
- package/engineering/tools/webots_mcp/controller_worker.py +124 -0
- package/engineering/tools/webots_mcp/evidence.py +288 -0
- package/engineering/tools/webots_mcp/fake_worker.py +69 -0
- package/engineering/tools/webots_mcp/fault_resources.py +121 -0
- package/engineering/tools/webots_mcp/host.py +45 -0
- package/engineering/tools/webots_mcp/ledger.py +73 -0
- package/engineering/tools/webots_mcp/owned_job.py +70 -0
- package/engineering/tools/webots_mcp/physical_gate.py +187 -0
- package/engineering/tools/webots_mcp/physics.py +152 -0
- package/engineering/tools/webots_mcp/pipeline.py +108 -0
- package/engineering/tools/webots_mcp/recipes.py +129 -0
- package/engineering/tools/webots_mcp/repair.py +132 -0
- package/engineering/tools/webots_mcp/repair_trial.py +375 -0
- package/engineering/tools/webots_mcp/runtime.py +311 -0
- package/engineering/tools/webots_mcp/server.py +217 -0
- package/engineering/tools/webots_mcp/sw_export.py +509 -0
- package/engineering/tools/webots_mcp/transport.py +156 -0
- package/engineering/tools/workflow_gate.py +6134 -0
- 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
|
+
|