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,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
|
+
|