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,124 @@
1
+ """Executed only by the owned webots-controller launcher; one Supervisor, serial RPC."""
2
+ import hashlib
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import socket
7
+
8
+
9
+ def main():
10
+ from controller import Supervisor, Motor, PositionSensor
11
+ e = os.environ
12
+ robot = Supervisor()
13
+ world = Path(robot.getWorldPath()).resolve(strict=True)
14
+ digest = hashlib.sha256(world.read_bytes()).hexdigest()
15
+ if (robot.getName() != e["A04_ROBOT"] or not world.samefile(e["A04_WORLD"])
16
+ or digest != e["A04_HASH"] or not robot.getSupervisor()):
17
+ raise RuntimeError("Supervisor identity mismatch")
18
+ step = robot.getBasicTimeStep()
19
+ if not 0 < step <= 1000 or not step.is_integer():
20
+ raise ValueError("Only integral basicTimeStep in 1..1000ms supported")
21
+ step = int(step)
22
+ completed = 0
23
+ with socket.create_connection(("127.0.0.1", int(e["A04_RPC_PORT"])), timeout=5) as sock:
24
+ sock.settimeout(None)
25
+ with sock.makefile("rwb") as stream:
26
+ def send(data):
27
+ packet = json.dumps(data, allow_nan=False).encode()
28
+ if len(packet) > 60000:
29
+ raise ValueError("Observation exceeds frame budget")
30
+ stream.write(packet + b"\n")
31
+ stream.flush()
32
+ send(dict(token=e["A04_TOKEN"],pid=os.getpid(), robot=robot.getName(), world=str(world),
33
+ world_sha256=digest,run_id=e["A04_RUN"],generation=int(e["A04_GENERATION"]),port=int(e["A04_PORT"]),
34
+ node_id=robot.getSelf().getId(),time=robot.getTime(),basic_time_step_ms=step))
35
+ while raw := stream.readline(65536):
36
+ cmd = json.loads(raw)
37
+ name, a = cmd["name"], cmd["args"]
38
+ data = {}
39
+ if name == "sim_step":
40
+ for _ in range(a["steps"]):
41
+ if robot.step(step) == -1:
42
+ raise RuntimeError("Simulation disconnected")
43
+ completed += 1
44
+ send(dict(progress=True, completed_steps=completed, sim_time=robot.getTime()))
45
+ elif name in ("sim_field","sim_contacts"):
46
+ node = robot.getFromDef(a["node"])
47
+ if node is None:
48
+ raise ValueError("Unknown DEF")
49
+ data = dict(node_id=node.getId(), node_def=a["node"])
50
+ if name == "sim_contacts":
51
+ if node.getBaseTypeName() not in ("Solid","Robot"):
52
+ raise ValueError("Contact scope requires an explicit Solid or Robot")
53
+ points = node.getContactPoints(False)
54
+ data["include_descendants"] = False
55
+ data["position_m"] = node.getPosition()
56
+ data["velocity_world"] = node.getVelocity()
57
+ for field_name in ("boundingObject","physics"):
58
+ field = node.getBaseNodeField(field_name) or node.getField(field_name)
59
+ field_node = field.getSFNode() if field else None
60
+ data[field_name] = None if field_node is None else {
61
+ "type":field_node.getBaseTypeName(),"node_id":field_node.getId()}
62
+ chain = []
63
+ parent = node
64
+ while parent is not None:
65
+ if len(chain) >= 64:
66
+ raise ValueError("Unsupported ancestor depth")
67
+ chain.append({"type":parent.getBaseTypeName(),"node_id":parent.getId()})
68
+ parent = parent.getParentNode()
69
+ data["ancestry"] = chain
70
+ if len(points) > 256:
71
+ raise ValueError("Contact snapshot exceeds supported count")
72
+ data["contacts"] = [dict(point=list(p.point), node_id=p.node_id) for p in points]
73
+ data["physical_verdict"] = "UNKNOWN"
74
+ else:
75
+ field = node.getField(a["field"])
76
+ getters = {"SFFloat":"getSFFloat","SFInt32":"getSFInt32","SFBool":"getSFBool",
77
+ "SFVec3f":"getSFVec3f","SFRotation":"getSFRotation","SFString":"getSFString"}
78
+ if field is None or field.getTypeName() not in getters:
79
+ raise ValueError("Unsupported field type")
80
+ data["value"] = getattr(field,getters[field.getTypeName()])()
81
+ elif name == "sim_devices":
82
+ n = robot.getNumberOfDevices()
83
+ if n > 128:
84
+ raise ValueError("Device enumeration exceeds limit")
85
+ data["devices"] = [robot.getDeviceByIndex(i).getName() for i in range(n)]
86
+ elif name == "sim_motor":
87
+ device = robot.getDevice(a["device"])
88
+ if not isinstance(device, Motor):
89
+ raise ValueError("Expected Motor")
90
+ device.setPosition(a.get("position", float("inf")))
91
+ device.setVelocity(a["velocity"])
92
+ data["feedback_verified"] = False
93
+ elif name == "sim_sensor":
94
+ sensor = robot.getDevice(a["device"])
95
+ if not isinstance(sensor, PositionSensor):
96
+ raise ValueError("Only PositionSensor supported")
97
+ if sensor.getSamplingPeriod() <= 0:
98
+ sensor.enable(step)
99
+ if robot.step(step) == -1:
100
+ raise RuntimeError("Sensor step disconnected")
101
+ completed += 1
102
+ send(dict(progress=True,completed_steps=completed,sim_time=robot.getTime()))
103
+ units = {PositionSensor.LINEAR:"m",PositionSensor.ROTATIONAL:"rad"}
104
+ data.update(device=a["device"],value=sensor.getValue(),unit=units[sensor.getType()],
105
+ device_type="PositionSensor")
106
+ elif name == "sim_scene":
107
+ data.update(world_sha256=digest,robot=robot.getName(),node_id=robot.getSelf().getId())
108
+ elif name == "sim_screenshot":
109
+ path = Path(e["A04_OUTPUT"]) / ("capture-" + a["operation_id"] + ".jpg")
110
+ robot.exportImage(str(path), 90)
111
+ if robot.step(0) == -1 or not path.is_file() or not 0 < path.stat().st_size <= 4194304:
112
+ raise RuntimeError("Image export unconfirmed")
113
+ data.update(image=path.name,sha256=hashlib.sha256(path.read_bytes()).hexdigest())
114
+ elif name == "sim_shutdown":
115
+ robot.simulationQuit(0)
116
+ else:
117
+ raise ValueError("Unsupported operation")
118
+ send(dict(done=True,data=data,sim_time=robot.getTime(),completed_steps=completed))
119
+ if name == "sim_shutdown":
120
+ return
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
@@ -0,0 +1,288 @@
1
+ """Small JSON report and bounded UTF-8 log carrier; no simulation or inference.
2
+
3
+ Callers supply the already verified _store.state_dir() and authenticated context.
4
+ Run ownership is exclusive; report/log updates otherwise require one writer per
5
+ run. This module does not implement the A04 action ledger or instance lock.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import codecs
10
+ from copy import deepcopy
11
+ from datetime import datetime, timezone
12
+ import hashlib
13
+ import json
14
+ import os
15
+ from pathlib import Path, PurePosixPath, PureWindowsPath
16
+ import re
17
+ import stat
18
+ import tempfile
19
+
20
+ REPORT_TYPES = ("model_manifest", "conversion_report", "experiment_report")
21
+ MAX_REPORT_BYTES = 1024 * 1024
22
+ MAX_LOG_BYTES = 1024 * 1024
23
+ MAX_PAGE_BYTES = 64 * 1024
24
+ _PROVENANCE = ("synthetic", "fixture", "fake", "static-derived", "real-sw-export", "real-webots", "unknown")
25
+ _STATUS = ("supported", "approximate", "unsupported", "unknown")
26
+ _DEVICES = {"CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10))}
27
+ _DEFAULTS = {
28
+ "model_manifest": {"source": None, "components": [], "raw_mass_properties": [], "rigid_bodies": [], "joints": []},
29
+ "conversion_report": {"inputs": [], "outputs": [], "tools": [], "mappings": [], "approximations": [],
30
+ "omissions": [], "support": [], "validations": [], "physical_validation": "unknown"},
31
+ "experiment_report": {"model": None, "scene": None, "parameters": {"time_step": None, "seed": None,
32
+ "initial_state": None, "budget": None}, "actions": [], "observations": [], "issues": [],
33
+ "verdict": {"status": "unknown", "reason": "not evaluated by evidence carrier"}},
34
+ }
35
+
36
+
37
+ def _identifier(value):
38
+ if (not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}", value)
39
+ or value.upper() in _DEVICES):
40
+ raise ValueError("invalid single-segment identifier")
41
+ return value
42
+
43
+
44
+ def _relative(value):
45
+ if not isinstance(value, str) or not value or "\\" in value:
46
+ raise ValueError("invalid relative path")
47
+ parts = value.split("/")
48
+ if PurePosixPath(value).is_absolute() or PureWindowsPath(value).drive:
49
+ raise ValueError("absolute paths are forbidden")
50
+ for part in parts:
51
+ if (part in {"", ".", ".."} or part.endswith((".", " "))
52
+ or any(ord(c) < 32 or c in '<>:"\\|?*' for c in part)
53
+ or part.split(".")[0].upper() in _DEVICES):
54
+ raise ValueError("invalid relative path segment")
55
+ return parts
56
+
57
+
58
+ def _no_reparse(path):
59
+ try:
60
+ info = path.lstat()
61
+ except FileNotFoundError:
62
+ return
63
+ if stat.S_ISLNK(info.st_mode) or getattr(info, "st_file_attributes", 0) & stat.FILE_ATTRIBUTE_REPARSE_POINT:
64
+ raise ValueError("symlink/reparse paths are forbidden")
65
+
66
+
67
+ def _provenance(value):
68
+ value = {"kind": value} if isinstance(value, str) else deepcopy(value)
69
+ if not isinstance(value, dict) or value.get("kind") not in _PROVENANCE:
70
+ raise ValueError("explicit provenance kind required")
71
+ return value
72
+
73
+
74
+ def _json_bytes(value):
75
+ encoded = (json.dumps(value, ensure_ascii=False, allow_nan=False, indent=2) + "\n").encode("utf-8")
76
+ if len(encoded) > MAX_REPORT_BYTES:
77
+ raise ValueError("report exceeds byte limit")
78
+ return encoded
79
+
80
+
81
+ def _load_json(path):
82
+ with path.open("rb") as stream:
83
+ raw = stream.read(MAX_REPORT_BYTES + 1)
84
+ if len(raw) > MAX_REPORT_BYTES:
85
+ raise ValueError("report exceeds byte limit")
86
+ value = json.loads(raw)
87
+ _json_bytes(value) # Reject NaN/Infinity and overflowed JSON floats on reads too.
88
+ return value
89
+
90
+
91
+ def _atomic_write(path, data):
92
+ descriptor, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
93
+ try:
94
+ with os.fdopen(descriptor, "wb") as stream:
95
+ stream.write(data)
96
+ stream.flush()
97
+ os.fsync(stream.fileno())
98
+ os.replace(name, path)
99
+ finally:
100
+ Path(name).unlink(missing_ok=True)
101
+
102
+
103
+ class EvidenceStore:
104
+ def __init__(self, state_dir, *, task_id, room_id):
105
+ root = Path(state_dir)
106
+ if not root.is_absolute() or not root.is_dir():
107
+ raise ValueError("state_dir must be an existing absolute directory")
108
+ _no_reparse(root)
109
+ for identity in (task_id, room_id):
110
+ if not isinstance(identity, str) or not identity.strip() or len(identity) > 256:
111
+ raise ValueError("nonempty task_id and room_id required")
112
+ self.root = root.resolve(strict=True)
113
+ self.task_id, self.room_id = task_id, room_id
114
+
115
+ def _path(self, relative):
116
+ path = self.root
117
+ for part in _relative(relative):
118
+ path = path / part
119
+ _no_reparse(path)
120
+ if not path.resolve().is_relative_to(self.root):
121
+ raise ValueError("path outside state_dir")
122
+ return path
123
+
124
+ def _owner(self, value, run_id):
125
+ if not isinstance(value, dict) or value.get("run_id") != run_id:
126
+ raise ValueError("invalid/incomplete run ownership")
127
+ if value.get("task_id") != self.task_id or value.get("room_id") != self.room_id:
128
+ raise PermissionError("run belongs to another task/room")
129
+
130
+ def _run(self, run_id, *, claim=False):
131
+ run_id = _identifier(run_id)
132
+ path = self._path(f"webots_runs/{run_id}")
133
+ owner_path = self._path(f"webots_runs/{run_id}/.owner.json")
134
+ claim_path = self._path(f"webots_runs/{run_id}/.owner")
135
+ if claim:
136
+ path.mkdir(parents=True, exist_ok=True)
137
+ # Exclusive mkdir claims the run; only its winner may publish ownership.
138
+ # Failed publication leaves the claim directory and fails closed.
139
+ try:
140
+ claim_path.mkdir()
141
+ except FileExistsError:
142
+ pass
143
+ else:
144
+ _atomic_write(owner_path, _json_bytes({"schema_version": 1, "run_id": run_id,
145
+ "task_id": self.task_id, "room_id": self.room_id}))
146
+ if claim_path.exists() and not owner_path.exists():
147
+ raise ValueError("incomplete run ownership claim; preserve for diagnosis")
148
+ self._owner(_load_json(owner_path), run_id)
149
+ return path
150
+
151
+ def new_report(self, report_type, run_id, *, provenance, source_revision=None, status="unknown",
152
+ data=None, evidence=None, missing_information=None):
153
+ """Make an in-memory carrier. No raw values, units or identities are inferred."""
154
+ if report_type not in REPORT_TYPES or status not in _STATUS:
155
+ raise ValueError("unsupported report_type/status")
156
+ _identifier(run_id)
157
+ if data is not None and not isinstance(data, dict):
158
+ raise ValueError("report data must be an object")
159
+ payload = deepcopy(_DEFAULTS[report_type])
160
+ payload.update(deepcopy(data or {}))
161
+ missing = list(missing_information or [])
162
+ if source_revision is None and "source_revision" not in missing:
163
+ missing.append("source_revision")
164
+ return {"schema_version": 1, "report_type": report_type, "run_id": run_id,
165
+ "task_id": self.task_id, "room_id": self.room_id, "source_revision": source_revision,
166
+ "created_at": datetime.now(timezone.utc).isoformat(), "provenance": _provenance(provenance),
167
+ "status": status, "evidence": deepcopy(evidence or []), "missing_information": missing, "data": payload}
168
+
169
+ def _validate_report(self, report):
170
+ if not isinstance(report, dict):
171
+ raise ValueError("report must be an object")
172
+ required = {"schema_version", "report_type", "run_id", "task_id", "room_id", "source_revision",
173
+ "created_at", "provenance", "status", "evidence", "missing_information", "data"}
174
+ if not required <= report.keys() or type(report["schema_version"]) is not int or report["schema_version"] != 1:
175
+ raise ValueError("invalid report header/schema_version")
176
+ _identifier(report["run_id"])
177
+ self._owner(report, report["run_id"])
178
+ if report["report_type"] not in REPORT_TYPES or report["status"] not in _STATUS:
179
+ raise ValueError("unsupported report_type/status")
180
+ _provenance(report["provenance"])
181
+ if (not isinstance(report["data"], dict) or not isinstance(report["evidence"], list)
182
+ or not isinstance(report["missing_information"], list)
183
+ or any(not isinstance(v, str) for v in report["missing_information"])
184
+ or not isinstance(report["created_at"], str)
185
+ or (report["source_revision"] is not None and not isinstance(report["source_revision"], str))):
186
+ raise ValueError("invalid report fields")
187
+ return _json_bytes(report)
188
+
189
+ def _verify_evidence(self, report):
190
+ for item in report["evidence"]:
191
+ if not isinstance(item, dict) or not {"path", "sha256", "size_bytes", "kind", "provenance", "producer"} <= item.keys():
192
+ raise ValueError("incomplete evidence index")
193
+ actual = self.file_evidence(report["run_id"], item["path"], kind=item["kind"],
194
+ provenance=item["provenance"], producer=item["producer"])
195
+ if item["sha256"] != actual["sha256"] or item["size_bytes"] != actual["size_bytes"]:
196
+ raise ValueError("evidence hash/size changed")
197
+
198
+ def write_report(self, report):
199
+ """Persist only after validation. Errors propagate; last good report survives."""
200
+ raw = self._validate_report(report)
201
+ self._run(report["run_id"], claim=True)
202
+ self._verify_evidence(report)
203
+ path = self._path(f"webots_runs/{report['run_id']}/{report['report_type']}.json")
204
+ _atomic_write(path, raw)
205
+ return {"path": path.relative_to(self.root).as_posix(), "sha256": hashlib.sha256(raw).hexdigest(), "size_bytes": len(raw)}
206
+
207
+ def read_report(self, run_id, report_type):
208
+ if report_type not in REPORT_TYPES:
209
+ raise ValueError("unsupported report_type")
210
+ self._run(run_id)
211
+ value = _load_json(self._path(f"webots_runs/{run_id}/{report_type}.json"))
212
+ self._validate_report(value)
213
+ if value["run_id"] != run_id or value["report_type"] != report_type:
214
+ raise ValueError("report does not match requested run/type")
215
+ self._verify_evidence(value)
216
+ return value
217
+
218
+ def save_snapshot(self,run_id,filename,raw,*,kind,provenance,producer):
219
+ """Store an immutable bounded run-local observation and return its verified index."""
220
+ self._run(run_id,claim=True)
221
+ if len(_relative(filename)) != 1 or not isinstance(raw,bytes) or len(raw) > 4*1024**2:
222
+ raise ValueError("Invalid observation snapshot")
223
+ path = self._path(f"webots_runs/{run_id}/{filename}")
224
+ with path.open("xb") as stream:
225
+ stream.write(raw)
226
+ stream.flush()
227
+ os.fsync(stream.fileno())
228
+ return self.file_evidence(run_id,filename,kind=kind,provenance=provenance,producer=producer)
229
+
230
+ def file_evidence(self, run_id, relative_path, *, kind, provenance, producer=None):
231
+ """Hash an existing run-local snapshot; versions unknown to the caller stay null."""
232
+ self._run(run_id)
233
+ _relative(relative_path)
234
+ path = self._path(f"webots_runs/{run_id}/{relative_path}")
235
+ if not isinstance(kind, str) or not kind:
236
+ raise ValueError("evidence kind required")
237
+ producer = deepcopy(producer) if producer is not None else {"name": None, "version": None, "source_revision": None}
238
+ if not isinstance(producer, dict):
239
+ raise ValueError("producer must be an object")
240
+ digest, size = hashlib.sha256(), 0
241
+ with path.open("rb") as stream:
242
+ for chunk in iter(lambda: stream.read(64 * 1024), b""):
243
+ digest.update(chunk)
244
+ size += len(chunk)
245
+ return {"path": relative_path, "sha256": digest.hexdigest(), "size_bytes": size,
246
+ "kind": kind, "provenance": _provenance(provenance), "producer": producer}
247
+
248
+ def append_log(self, run_id, text, *, name="worker", max_bytes=MAX_LOG_BYTES):
249
+ self._run(run_id)
250
+ _identifier(name)
251
+ if type(max_bytes) is not int or not 4 <= max_bytes <= MAX_LOG_BYTES or not isinstance(text, str):
252
+ raise ValueError("invalid log text/byte limit")
253
+ path = self._path(f"webots_runs/{run_id}/logs/{name}.log")
254
+ path.parent.mkdir(exist_ok=True)
255
+ previous, total = b"", 0
256
+ if path.exists():
257
+ with path.open("rb") as stream:
258
+ total = stream.seek(0, os.SEEK_END)
259
+ stream.seek(max(0, total - max_bytes))
260
+ previous = stream.read(max_bytes)
261
+ added = text.encode("utf-8")
262
+ # ponytail: one bounded tail per log, archive failure snapshots separately if long history is needed.
263
+ kept = (previous + added)[-max_bytes:].decode("utf-8", errors="ignore").encode("utf-8")
264
+ _atomic_write(path, kept)
265
+ return {"path": f"logs/{name}.log", "size_bytes": len(kept), "dropped_bytes": total + len(added) - len(kept)}
266
+
267
+ def read_log(self, run_id, *, name="worker", offset=0, limit=16384, expected_sha256=None):
268
+ self._run(run_id)
269
+ _identifier(name)
270
+ if type(offset) is not int or offset < 0 or type(limit) is not int or not 4 <= limit <= MAX_PAGE_BYTES:
271
+ raise ValueError("invalid byte offset/page limit")
272
+ path = self._path(f"webots_runs/{run_id}/logs/{name}.log")
273
+ with path.open("rb") as stream:
274
+ raw = stream.read(MAX_LOG_BYTES + 1)
275
+ if len(raw) > MAX_LOG_BYTES or offset > len(raw):
276
+ raise ValueError("log byte limit/offset exceeded")
277
+ digest = hashlib.sha256(raw).hexdigest()
278
+ if expected_sha256 is not None and expected_sha256 != digest:
279
+ raise ValueError("log snapshot changed; restart pagination")
280
+ decoder = codecs.getincrementaldecoder("utf-8")()
281
+ chunk = raw[offset:offset + limit]
282
+ try:
283
+ text = decoder.decode(chunk, final=offset + len(chunk) == len(raw))
284
+ except UnicodeDecodeError as error:
285
+ raise ValueError("invalid UTF-8 log or byte offset") from error
286
+ next_offset = offset + len(chunk) - len(decoder.getstate()[0])
287
+ return {"path": f"logs/{name}.log", "text": text, "offset": offset, "next_offset": next_offset,
288
+ "size_bytes": len(raw), "sha256": digest, "eof": next_offset == len(raw)}
@@ -0,0 +1,69 @@
1
+ """Explicit, in-memory fixtures. No Webots, persistence, queue, or recovery."""
2
+ from copy import deepcopy
3
+ from uuid import uuid4
4
+
5
+
6
+ class FakeWorker:
7
+ def __init__(self, mode):
8
+ if mode not in ("complete", "error", "timeout", "unknown"):
9
+ raise ValueError("Unknown fake worker fixture")
10
+ self.mode = mode
11
+ self.runs = {}
12
+ self.jobs = {}
13
+ self.operations = set()
14
+
15
+ def check_identity(self, args):
16
+ run = self.runs.get(args.get("run_id"))
17
+ if run is None:
18
+ return "RUN_NOT_FOUND"
19
+ if any(args[key] != run[key] for key in ("task_id", "room_id")):
20
+ return "OWNERSHIP_MISMATCH"
21
+ if args["session_generation"] != run["session_generation"]:
22
+ return "STALE_GENERATION"
23
+ return None
24
+
25
+ def dispatch(self, name, args, result):
26
+ if name == "sim_status":
27
+ result.update(ok=True, job_state="NEW", execution_state="not_started")
28
+ return result
29
+ if name not in ("sim_launch", "sim_convert"):
30
+ error = self.check_identity(args)
31
+ if error:
32
+ result["error_code"] = error
33
+ return result
34
+ if name in ("sim_job", "sim_cancel"):
35
+ job = self.jobs.get(args["job_id"])
36
+ if job is None or job["run_id"] != args["run_id"]:
37
+ result["error_code"] = "JOB_NOT_FOUND"
38
+ return result
39
+ if name == "sim_job":
40
+ snapshot = deepcopy(job)
41
+ snapshot["request_id"] = args["request_id"]
42
+ return snapshot
43
+ if name in ("sim_cancel", "sim_reset"):
44
+ result.update(error_code="CORE_NOT_IMPLEMENTED", hint="A04 must implement cancellation/reset semantics.")
45
+ return result
46
+ operation = args.get("operation_id")
47
+ if operation:
48
+ # ponytail: fixture-only memory; A04 supplies the durable operation ledger.
49
+ key = (args["task_id"], args["room_id"], args.get("run_id"), operation)
50
+ if key in self.operations:
51
+ result["error_code"] = "OPERATION_REUSED"
52
+ result["hint"] = "Fixture refuses replay; this is not durable deduplication."
53
+ return result
54
+ self.operations.add(key)
55
+ if name in ("sim_launch", "sim_convert"):
56
+ result.update(run_id="fake-run-" + uuid4().hex, session_generation=1)
57
+ self.runs[result["run_id"]] = deepcopy(result)
58
+ result.update(job_id="fake-job-" + uuid4().hex,
59
+ data={"fixture": self.mode, "real_execution": False},
60
+ hint="Synthetic fixture only; real execution and physical validity are unverified.")
61
+ if self.mode == "complete":
62
+ result.update(ok=True, error_code=None, execution_state="completed", job_state="COMPLETED")
63
+ elif self.mode == "error":
64
+ result.update(error_code="WORKER_ERROR", job_state="FAILED")
65
+ else:
66
+ result.update(error_code="OUTCOME_UNKNOWN", execution_state="unknown", job_state="UNKNOWN",
67
+ hint=f"Fake {self.mode}; query this job, do not replay the operation.")
68
+ self.jobs[result["job_id"]] = deepcopy(result)
69
+ return result
@@ -0,0 +1,121 @@
1
+ """Read-only resource measurements and fault oracles; no worker/recovery implementation.
2
+
3
+ Windows sampling needs the already available pywin32 package. Nothing here opens
4
+ an arbitrary process for termination, launches Webots, or assigns a physical verdict.
5
+ """
6
+ from datetime import datetime, timezone
7
+ import math
8
+ import time
9
+
10
+ METRICS = frozenset(("wall_time_s", "cpu_time_s", "cpu_percent_one_core",
11
+ "working_set_bytes", "private_bytes", "process_count", "gpu_percent",
12
+ "log_bytes", "screenshot_bytes", "video_bytes"))
13
+
14
+
15
+ def _nonnegative(value):
16
+ return (type(value) is int and value >= 0) or (type(value) is float and math.isfinite(value) and value >= 0)
17
+
18
+
19
+ def budget_check(sample, limits):
20
+ """Check caller-supplied ceilings; None means unmeasured, never a budget pass."""
21
+ if not isinstance(sample, dict) or not isinstance(limits, dict) or not limits:
22
+ raise ValueError("A sample and nonempty explicit budget are required")
23
+ exceeded, unknown = [], []
24
+ for metric, ceiling in limits.items():
25
+ if metric not in METRICS or not _nonnegative(ceiling):
26
+ raise ValueError("Unsupported metric or invalid ceiling")
27
+ value = sample.get(metric)
28
+ if not _nonnegative(value):
29
+ unknown.append(metric)
30
+ elif value > ceiling:
31
+ exceeded.append(metric)
32
+ return {"within_budget": False if exceeded else (None if unknown else True),
33
+ "exceeded": exceeded, "unknown": unknown}
34
+
35
+
36
+ def sample_job(job, *, previous=None):
37
+ """Sample a caller-owned live Windows Job handle, without changing its state.
38
+
39
+ The first sample starts the wall interval. Subsequent samples must retain the
40
+ same open handle. Working sets are summed, including shared pages per process;
41
+ private_bytes is the sum of live processes' private commit, not physical RAM.
42
+ """
43
+ import win32api
44
+ import win32con
45
+ import win32job
46
+ import win32process
47
+
48
+ try:
49
+ job_key = int(job) # Process-local handle only, never a persistent ownership ID.
50
+ except (TypeError, ValueError, OverflowError) as error:
51
+ raise ValueError("An explicit live Job handle is required") from error
52
+ if isinstance(job, bool) or job_key <= 0:
53
+ raise ValueError("A null Job would query the host Job; an explicit handle is required")
54
+ now = time.monotonic()
55
+ accounting = win32job.QueryInformationJobObject(job, win32job.JobObjectBasicAccountingInformation)
56
+ cpu_time = (accounting["TotalUserTime"] + accounting["TotalKernelTime"]) / 10_000_000
57
+ started, cpu_percent = now, None
58
+ if previous is not None:
59
+ if (not isinstance(previous, dict) or previous.get("job_handle") != job_key
60
+ or not all(_nonnegative(previous.get(key)) for key in
61
+ ("sample_monotonic_s", "started_monotonic_s", "cpu_time_s"))
62
+ or previous["sample_monotonic_s"] >= now
63
+ or previous["started_monotonic_s"] > previous["sample_monotonic_s"]
64
+ or previous["cpu_time_s"] > cpu_time):
65
+ raise ValueError("Previous sample must belong to this live Job and precede this sample")
66
+ started = previous["started_monotonic_s"]
67
+ cpu_percent = 100 * (cpu_time - previous["cpu_time_s"]) / (now - previous["sample_monotonic_s"])
68
+ # ponytail: discrete samples can miss short peaks; use sustained sampling/native limits when enforcing budgets.
69
+ pids = win32job.QueryInformationJobObject(job, win32job.JobObjectBasicProcessIdList)
70
+ processes, missing = [], []
71
+ for pid in pids:
72
+ handle = None
73
+ try:
74
+ handle = win32api.OpenProcess(win32con.PROCESS_QUERY_INFORMATION | win32con.PROCESS_VM_READ,
75
+ False, pid)
76
+ if not win32job.IsProcessInJob(handle, job):
77
+ raise ValueError("PID no longer belongs to sampled Job")
78
+ memory = win32process.GetProcessMemoryInfo(handle)
79
+ processes.append({"pid": pid,
80
+ "creation_time": str(win32process.GetProcessTimes(handle)["CreationTime"]),
81
+ "exe": win32process.GetModuleFileNameEx(handle, 0),
82
+ "working_set_bytes": memory["WorkingSetSize"], "private_bytes": memory["PagefileUsage"]})
83
+ except (win32api.error, ValueError) as error:
84
+ missing.append({"pid": pid, "reason": str(error)})
85
+ finally:
86
+ if handle is not None:
87
+ handle.Close()
88
+ after_pids = win32job.QueryInformationJobObject(job, win32job.JobObjectBasicProcessIdList)
89
+ complete = not missing and set(pids) == set(after_pids) and len(pids) == accounting["ActiveProcesses"]
90
+ if not complete and not missing:
91
+ missing.append({"pid": None, "reason": "Job membership changed during sample"})
92
+ return {"schema_version": 1, "provenance": "host-measured", "scope": "windows-job",
93
+ "snapshot_at": datetime.now(timezone.utc).isoformat(), "job_handle": job_key,
94
+ "sample_monotonic_s": now, "started_monotonic_s": started,
95
+ "wall_time_s": now - started, "cpu_time_s": cpu_time,
96
+ "cpu_percent_one_core": cpu_percent, "process_count": accounting["ActiveProcesses"],
97
+ "working_set_bytes": sum(p["working_set_bytes"] for p in processes) if complete else None,
98
+ "private_bytes": sum(p["private_bytes"] for p in processes) if complete else None,
99
+ "gpu_percent": None, "complete": complete, "processes": processes,
100
+ "missing_information": missing + [{"metric": "gpu_percent", "reason": "Not measured"}]}
101
+
102
+
103
+ def assert_unknown_action(result, *, operation_id, session_generation, completed_steps, sim_time):
104
+ """A04 test oracle for a dispatched action whose final result was lost.
105
+
106
+ Caller supplies the last confirmed progress. This validates an observation;
107
+ it does not manufacture a result, retry an action, or implement recovery.
108
+ """
109
+ expected = {"ok": False, "error_code": "OUTCOME_UNKNOWN", "execution_state": "unknown",
110
+ "operation_id": operation_id, "session_generation": session_generation,
111
+ "completed_steps": completed_steps, "sim_time": sim_time}
112
+ if not isinstance(result, dict):
113
+ raise AssertionError("An uncertain action requires a result object")
114
+ for key, value in expected.items():
115
+ actual = result.get(key)
116
+ invalid_number = isinstance(value, (int, float)) and (
117
+ (type(value) is bool and type(actual) is not bool) or
118
+ (type(value) is not bool and (not _nonnegative(actual) or
119
+ (type(value) is int and type(actual) is not int))))
120
+ if key not in result or actual != value or invalid_number:
121
+ raise AssertionError(f"Uncertain action lost {key}: {result.get(key)!r}, expected {value!r}")
@@ -0,0 +1,45 @@
1
+ """Read-only S04 report/log bridge. The DSH host authenticates the caller context."""
2
+ import argparse
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from .evidence import EvidenceStore
7
+
8
+ def main():
9
+ parser=argparse.ArgumentParser(description=__doc__)
10
+ parser.add_argument('kind',choices=('report','evidence','attribute'))
11
+ parser.add_argument('--task-id',required=True)
12
+ parser.add_argument('--room-id',required=True)
13
+ parser.add_argument('--run-id',required=True)
14
+ parser.add_argument('--report-type',choices=('model_manifest','conversion_report','experiment_report'))
15
+ parser.add_argument('--issue-id')
16
+ parser.add_argument('--name',default='worker')
17
+ parser.add_argument('--offset',type=int,default=0)
18
+ parser.add_argument('--limit',type=int,default=16384)
19
+ parser.add_argument('--expected-sha256')
20
+ args=parser.parse_args()
21
+ declared=os.environ.get('DSH_STATE_DIR','')
22
+ if not declared or not Path(declared).is_absolute() or not Path(declared).is_dir():
23
+ parser.error('DSH_STATE_DIR must explicitly name an existing absolute directory')
24
+ from _store import state_dir
25
+ resolved=Path(state_dir()).resolve(strict=True)
26
+ if resolved != Path(declared).resolve(strict=True): parser.error('state_dir mismatch')
27
+ try:
28
+ store=EvidenceStore(resolved,task_id=args.task_id,room_id=args.room_id)
29
+ if args.kind=='report':
30
+ data=store.read_report(args.run_id,args.report_type)
31
+ elif args.kind=='attribute':
32
+ from .repair import attribute
33
+ from .evidence import _identifier
34
+ data=attribute(store,args.run_id,_identifier(args.issue_id))
35
+ else:
36
+ if not 4 <= args.limit <= 16384: raise ValueError('Host page limit must be 4..16384')
37
+ data=store.read_log(args.run_id,name=args.name,offset=args.offset,limit=args.limit,expected_sha256=args.expected_sha256)
38
+ result={'ok':True,'data':data}
39
+ except PermissionError: result={'ok':False,'error_code':'OWNERSHIP_MISMATCH'}
40
+ except FileNotFoundError: result={'ok':False,'error_code':'EVIDENCE_NOT_FOUND'}
41
+ except (ValueError,TypeError,KeyError,AttributeError) as error: result={'ok':False,'error_code':'EVIDENCE_INVALID','hint':str(error)[:256]}
42
+ except OSError as error: result={'ok':False,'error_code':'EVIDENCE_IO_ERROR','hint':str(error)[:256]}
43
+ print(json.dumps(result,ensure_ascii=False,allow_nan=False))
44
+ if __name__=='__main__': main()
45
+