feat: release v1.0.1 CADWorld 网站与 LeKiwi 智能抓放
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
web-platform-ci / Standalone decision service (no cloud credentials) (pull_request) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
lekiwi-compatibility / cpu-compatibility (pull_request) Has been cancelled

集成同源 BYOK 会话隔离、精简模型设置、官方订阅入口和 HTTPS 发布运维;保留本地训练/调参与控制能力。同步 npm 版本及 CHANGELOG,记录公网真实 API 验收仍待用户凭据。
This commit is contained in:
2026-09-24 09:57:41 +08:00
parent 3ad29356c9
commit f3a8a38acd
194 changed files with 32918 additions and 236 deletions
@@ -0,0 +1,75 @@
"""Deterministic native-math oracle, consumed by Vitest. Run from repository root."""
import json
from pathlib import Path
import numpy as np
from training_server.mobile_manipulator.kernel import ROBOTS, TASK, TaskKernel, decode_action
from training_server.mobile_manipulator.motion import SafeActionController
def generate():
rng = np.random.default_rng(2026)
cases = []
for config in ROBOTS:
kernel = TaskKernel(config)
for i in range(20):
s = rng.uniform(-2, 2, TASK["stateSize"])
for start in [3, 33, 40, 50]:
q = rng.normal(size=4)
s[start : start + 4] = q / np.linalg.norm(q)
s[29] = rng.uniform(0, 1)
action = rng.uniform(-1.5, 1.5, TASK["actionSize"]).astype(np.float32)
kernel.reset()
kernel.has_lifted = bool(i % 2)
obs, reward, terminated, truncated, info = kernel.evaluate(s)
cases.append(
{
"robotId": config["id"],
"state": s.tolist(),
"action": action.tolist(),
"lifted": bool(i % 2),
"observation": obs.tolist(),
"control": decode_action(config, action).tolist(),
"reward": reward,
"terminated": terminated,
"truncated": truncated,
"info": info,
}
)
target = Path(__file__).resolve().parents[2] / "contracts/fixtures/mobile-golden.json"
target.write_text(json.dumps(cases, indent=2) + "\n")
motion_cases = []
for config in ROBOTS:
for stage in ("navigate", "reach", "pick-place"):
s = np.zeros(TASK["stateSize"])
s[[3, 33, 40, 50]] = 1
s[37:40] = [*TASK["navigationOffset"][:2], TASK["objectStart"][2]]
s[29] = 1
for i, joint in enumerate(config["armJoints"]):
s[13 + i] = joint["neutral"]
motion = SafeActionController(config)
motion.reset(s)
frames = []
for _ in range(12):
action = rng.uniform(-2, 2, TASK["actionSize"]).astype(np.float32)
motion.apply(action, s, stage, False)
frames.append(
dict(
action=action.tolist(),
control=motion.control.tolist(),
applied=motion.applied.tolist(),
targets=motion.targets.tolist(),
)
)
motion_cases.append(
dict(robotId=config["id"], stage=stage, state=s.tolist(), frames=frames)
)
target.with_name("mobile-motion-v2-golden.json").write_text(
json.dumps(motion_cases, indent=2) + "\n"
)
if __name__ == "__main__":
generate()
@@ -0,0 +1,224 @@
import copy
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
from xml.etree import ElementTree as ET
import numpy as np
from training_server.mobile_manipulator.kernel import (
CONTRACTS,
ROBOTS,
TASK,
TaskKernel,
decode_action,
)
def make_package(path):
import mujoco
config = copy.deepcopy(ROBOTS[0])
config["recipe"] = "mjcf"
root = ET.Element("mujoco")
ET.SubElement(root, "compiler", angle="radian")
ET.SubElement(root, "option", timestep=".002", gravity="0 0 0", integrator="implicitfast")
world = ET.SubElement(root, "worldbody")
base = ET.SubElement(world, "body", name=config["baseBodyName"], pos="0 0 .1")
ET.SubElement(base, "freejoint", name=config["baseJointName"])
ET.SubElement(base, "geom", type="sphere", size=".05", mass="2", contype="0", conaffinity="0")
actuators = ET.SubElement(root, "actuator")
for name in config["baseJoints"]:
body = ET.SubElement(base, "body")
ET.SubElement(body, "joint", name=name, axis="0 1 0", damping=".1")
ET.SubElement(
body, "geom", type="sphere", size=".02", mass=".1", contype="0", conaffinity="0"
)
ET.SubElement(
actuators,
"velocity",
name=name + "_servo",
joint=name,
kv="1",
ctrllimited="true",
ctrlrange=f"{-config['wheelLimit']} {config['wheelLimit']}",
)
for j in [
*config["armJoints"],
dict(name=config["gripperJoint"], min=config["gripperClosed"], max=config["gripperOpen"]),
]:
body = ET.SubElement(base, "body", pos="0 0 .1")
if j["name"] == config["armJoints"][-1]["name"]:
body.set("name", config["eefBodyName"])
ET.SubElement(body, "site", name=config["eefSiteName"], pos=".1 0 0")
ET.SubElement(
body,
"joint",
name=j["name"],
axis="0 0 1",
limited="true",
range=f"{j['min']} {j['max']}",
damping=".1",
armature=".01",
)
ET.SubElement(
body, "geom", type="sphere", size=".02", mass=".1", contype="0", conaffinity="0"
)
ET.SubElement(
actuators,
"position",
name=j["name"] + "_servo",
joint=j["name"],
kp="10",
kv="1",
ctrllimited="true",
ctrlrange=f"{j['min']} {j['max']}",
)
body = ET.SubElement(
world, "body", name="__mm_object", pos=" ".join(map(str, TASK["objectStart"]))
)
ET.SubElement(body, "freejoint", name="__mm_object_joint")
ET.SubElement(body, "geom", type="box", size=".018 .018 .018", mass=".04")
ET.SubElement(
world, "body", name="__mm_goal", mocap="true", pos=" ".join(map(str, TASK["goalStart"]))
)
(path / "scene.xml").write_text(ET.tostring(root, encoding="unicode"))
(path / "robot.json").write_text(json.dumps(config, separators=(",", ":")))
(path / "task.json").write_text(json.dumps(TASK))
(path / "environment.json").write_text(
json.dumps(
dict(
scene="scene.xml",
mujoco=mujoco.__version__,
taskId=TASK["id"],
robotId=config["id"],
)
)
)
class KernelTests(unittest.TestCase):
def test_golden_and_reusable_buffers(self):
cases = json.loads((CONTRACTS / "fixtures/mobile-golden.json").read_text())
for case in cases:
config = next(c for c in ROBOTS if c["id"] == case["robotId"])
kernel = TaskKernel(config)
kernel.has_lifted = case["lifted"]
observation, reward, *_, info = kernel.evaluate(np.array(case["state"]))
np.testing.assert_allclose(observation, case["observation"], atol=1e-7)
np.testing.assert_allclose(
decode_action(config, case["action"]), case["control"], atol=1e-12
)
self.assertAlmostEqual(reward, case["reward"], places=12)
self.assertEqual(info["stage"], case["info"]["stage"])
self.assertIs(kernel.observe(np.array(case["state"])), observation)
def test_invalid_action_is_atomic_and_velocity_mode(self):
config = copy.deepcopy(ROBOTS[0])
config["armJoints"][0]["mode"] = "velocity"
output = np.ones(9)
action = np.zeros(12, dtype=np.float32)
action[3] = 0.5
decode_action(config, action, output)
self.assertEqual(output[3], 1)
previous = output.copy()
action[11] = np.nan
with self.assertRaises(ValueError):
decode_action(config, action, output)
np.testing.assert_equal(previous, output)
@unittest.skipUnless(
importlib.util.find_spec("mujoco") and importlib.util.find_spec("gymnasium"),
"install mobile_manipulator/requirements.txt for native tests",
)
class NativeTests(unittest.TestCase):
def setUp(self):
from training_server.mobile_manipulator.env import MobileManipulatorEnv
self.directory = tempfile.TemporaryDirectory()
self.path = Path(self.directory.name)
make_package(self.path)
self.env = MobileManipulatorEnv(self.path)
def tearDown(self):
self.env.close()
self.directory.cleanup()
def test_gym_contract_and_reset(self):
from gymnasium.utils.env_checker import check_env
check_env(self.env, skip_render_check=True)
obs, _ = self.env.reset(seed=42)
action = self.env.action.copy()
action[3] = 0.2
stepped = self.env.step(action)
self.assertTrue(np.isfinite(stepped[0]).all())
self.assertFalse(np.shares_memory(obs, stepped[0]))
self.assertAlmostEqual(self.env.data.time, TASK["controlDt"])
self.env.move_task_entity("goal", [0.7, 0.2, 0.019])
np.testing.assert_allclose(self.env.state[47:50], [0.7, 0.2, 0.019])
self.assertEqual(self.env.kernel.steps, 0)
self.env.kernel.has_lifted = True
self.env.move_task_entity("object", [0.3, 0.1, 0.02])
self.assertFalse(self.env.kernel.has_lifted)
self.assertEqual(self.env.data.qvel[self.env.object_v : self.env.object_v + 6].sum(), 0)
self.env.close()
with self.assertRaises(RuntimeError):
self.env.step(action)
def test_navigation_holds_arm_and_has_observable_controller_state(self):
self.env.kernel.stage = "navigate"
self.env.reset(seed=7)
before = self.env.control.copy()
obs, _, _, _, info = self.env.step(np.ones(TASK["actionSize"], dtype=np.float32))
np.testing.assert_allclose(self.env.control[3:], before[3:])
np.testing.assert_equal(obs[68:80], self.env.motion.applied)
np.testing.assert_equal(obs[80:92], self.env.motion.targets)
self.assertEqual(info["safety_stop"], "")
self.assertLess(info["max_joint_velocity"], TASK["jointSpeedStop"])
self.env.data.qvel[self.env.arm[0][1]] = 30
self.assertTrue(self.env.step(np.zeros(12, dtype=np.float32))[2])
self.assertEqual(self.env.kernel.info["safety_stop"], "joint_velocity")
def test_hold_does_not_ratchet_targets_toward_gravity_sag(self):
import mujoco
previous = self.env.control.copy()
self.env.data.qpos[self.env.arm[0][0]] -= 0.1
mujoco.mj_forward(self.env.model, self.env.data)
for _ in range(10):
self.env.hold()
np.testing.assert_equal(self.env.control, previous)
def test_randomized_reset_is_seeded_and_evaluation_can_be_fixed(self):
self.env.position_jitter = 0.1
first, _ = self.env.reset(seed=7)
second, _ = self.env.reset(seed=7)
third, _ = self.env.reset(seed=8)
np.testing.assert_equal(first, second)
self.assertFalse(np.array_equal(first, third))
self.env.position_jitter = 0
first, _ = self.env.reset(seed=7)
second, _ = self.env.reset(seed=8)
np.testing.assert_equal(first, second)
def test_reject_mismatching_model_and_version(self):
from training_server.mobile_manipulator.env import MobileManipulatorEnv
meta = json.loads((self.path / "environment.json").read_text())
meta["mujoco"] = "0.0.0"
(self.path / "environment.json").write_text(json.dumps(meta))
with self.assertRaisesRegex(ValueError, "version mismatch"):
MobileManipulatorEnv(self.path)
config = json.loads((self.path / "robot.json").read_text())
config["armJoints"][0]["max"] = 1
(self.path / "robot.json").write_text(json.dumps(config))
with self.assertRaisesRegex(ValueError, "joint range"):
MobileManipulatorEnv(self.path, allow_version_mismatch=True)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,273 @@
"""One-click API/runner tests. No CUDA, mjlab or large robot assets required."""
import hashlib
import io
import json
import sys
import tempfile
import threading
import time
import unittest
import zipfile
from http.server import ThreadingHTTPServer
from pathlib import Path
from unittest.mock import patch
from urllib.error import HTTPError
from urllib.request import Request, urlopen
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from mobile_manipulator.config import MOBILE_TASKS, ROBOTS, TASK
from server import DEFAULT_TASKS, ApiError, TrainingJob, TrainingManager, TrainingRequestHandler
TASK_ID = next(iter(MOBILE_TASKS))
def archive(robot_id="lekiwi-v1", extra=None):
files = {
"robot.json": json.dumps(ROBOTS[robot_id], separators=(",", ":")),
"task.json": json.dumps(TASK),
"environment.json": json.dumps(
dict(scene="scene.xml", robotId=robot_id, taskId=TASK["id"], mujoco="3.11.0")
),
"scene.xml": "<mujoco><worldbody/></mujoco>",
}
files.update(extra or {})
stream = io.BytesIO()
with zipfile.ZipFile(stream, "w") as z:
for name, value in files.items():
z.writestr(zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)), value)
return stream.getvalue()
class MobileTrainingTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.manager = TrainingManager(
self.root, sys.executable, DEFAULT_TASKS, check_environment=False
)
raw = archive()
self.package = self.manager.mobile_packages.receive(io.BytesIO(raw), len(raw))
def tearDown(self):
self.manager.shutdown()
self.temp.cleanup()
def payload(self, **changes):
result = dict(
taskId=TASK_ID,
numEnvs=2,
maxIterations=2,
seed=123,
runName="mobile",
device="cpu",
gpuIds=[],
mobilePackageId=self.package["id"],
mobileParams=dict(rolloutSteps=8, goalPosition=[0.5, 0.4, 0.05]),
)
result.update(changes)
return result
def test_registry_readiness_is_per_family(self):
health = self.manager.health()
self.assertTrue(health["ready"])
mobile = [m for m in health["taskMetadata"] if m.get("family") == "mobile-manipulator"]
self.assertEqual(len(mobile), 2)
self.assertTrue(all(m["ready"] and not m["terrainPresets"] for m in mobile))
self.assertFalse(health["taskMetadata"][0]["ready"])
def test_validation_variant_and_parameter_bounds(self):
config = self.manager.parse_config(self.payload())
self.assertEqual(config.seed, 123)
self.assertEqual(config.mobile_params["rolloutSteps"], 8)
self.assertEqual(config.mobile_params["goalPosition"], [0.5, 0.4, 0.05])
self.assertEqual(config.deployment["sceneSha256"], self.package["sceneSha256"])
for fields in [
dict(taskId="MobileManipulator-LeKiwi-Bundle"),
dict(numEnvs=65),
dict(mobilePackageId="../../etc"),
dict(terrainPreset="plane"),
dict(mobileParams={"rolloutSteps": True}),
dict(mobileParams={"rolloutSteps": 7}),
dict(mobileParams={"stage": "fly"}),
dict(mobileParams={"stage": "reach"}),
dict(mobileParams={"sourceJobId": "../../untrusted.zip"}),
dict(mobileParams={"positionJitter": float("nan")}),
dict(mobileParams={"evaluationEpisodes": True}),
dict(mobileParams={"goalPosition": [0, float("nan"), 1]}),
dict(mobileParams={"goalPosition": [0, 0, -1]}),
dict(device="gpu", gpuIds=[0, 1]),
dict(pretrainedSourceId="abc"),
]:
with self.subTest(fields=fields), self.assertRaises(ApiError):
self.manager.parse_config(self.payload(**fields))
bundle = archive("lekiwi-bundle")
uploaded = self.manager.mobile_packages.receive(io.BytesIO(bundle), len(bundle))
self.assertEqual(
self.manager.parse_config(
self.payload(
taskId="MobileManipulator-LeKiwi-Bundle", mobilePackageId=uploaded["id"]
)
).deployment["robotId"],
"lekiwi-bundle",
)
def test_runner_arguments_use_server_owned_paths_and_mobile_interpreter(self):
self.manager.mobile_python = "/isolated/mobile/python"
args = self.manager.command_for(
self.manager.parse_config(self.payload(device="gpu", gpuIds=[2]))
)
self.assertEqual(args[0], self.manager.mobile_python)
self.assertIn("training_server.mobile_manipulator.train", args)
self.assertIn("cuda:2", args)
self.assertEqual(args[args.index("--seed") + 1], "123")
self.assertEqual(args[args.index("--num-envs") + 1], "2")
self.assertTrue(Path(args[args.index("--package") + 1]).is_relative_to(self.root))
def test_upload_rejects_traversal_xml_external_paths_and_wrong_contract(self):
for extra in [
{"../escape": "x"},
{"scene.xml": '<mujoco><include file="a.xml"/></mujoco>'},
{"scene.xml": '<mujoco><asset><mesh file="/etc/passwd"/></asset></mujoco>'},
{"scene.xml": '<mujoco><extension><plugin plugin="bad"/></extension></mujoco>'},
{"robot.json": "{}"},
{"robot.json": "[]"},
{"environment.json": "[]"},
{"task.json": "{}"},
{"./scene.xml": "<mujoco/>"},
{"scene.xml": '<mujoco><asset><texture fileleft="/etc/passwd"/></asset></mujoco>'},
]:
data = archive(extra=extra)
with self.subTest(extra=extra), self.assertRaises(ValueError):
self.manager.mobile_packages.receive(io.BytesIO(data), len(data))
with self.assertRaises(ValueError):
self.manager.mobile_packages.receive(io.BytesIO(b"bad"), 3)
with patch("mobile_manipulator.packages.MAX_EXPANDED", 1), self.assertRaises(ValueError):
data = archive(extra={"extra.txt": "other"})
self.manager.mobile_packages.receive(io.BytesIO(data), len(data))
def test_staged_resume_requires_matching_successful_evaluated_job(self):
source = TrainingJob(
id="d" * 32, config=self.manager.parse_config(self.payload()), state="succeeded"
)
source.artifact = self.root / "policy.onnx"
source.artifact.write_bytes(b"onnx")
source.artifact.with_suffix(".ppo.zip").write_bytes(b"trusted-checkpoint")
self.manager.jobs[source.id] = source
params = source.config.mobile_params | {"stage": "reach", "sourceJobId": source.id}
with self.assertRaisesRegex(ApiError, "尚未达标"):
self.manager.parse_config(self.payload(mobileParams=params))
source.config.deployment["evaluation"] = {
"episodes": 10,
"successRate": 0.8,
"safetyStops": 0,
}
config = self.manager.parse_config(self.payload(mobileParams=params))
command = self.manager.command_for(config)
self.assertEqual(
command[command.index("--resume") + 1], str(source.artifact.with_suffix(".ppo.zip"))
)
with self.assertRaisesRegex(ApiError, "依次推进"):
self.manager.parse_config(self.payload(mobileParams=params | {"stage": "pick-place"}))
source.config.deployment["evaluation"]["safetyStops"] = 1
with self.assertRaisesRegex(ApiError, "尚未达标"):
self.manager.parse_config(self.payload(mobileParams=params))
# Poor quality still permits same-stage continuation, never a stage promotion.
self.manager.parse_config(self.payload(mobileParams=params | {"stage": "navigate"}))
changed = archive(extra={"mesh.txt": "different bytes"})
other_package = self.manager.mobile_packages.receive(io.BytesIO(changed), len(changed))
with self.assertRaisesRegex(ApiError, "资产快照不匹配"):
self.manager.parse_config(
self.payload(
mobilePackageId=other_package["id"], mobileParams=params | {"stage": "navigate"}
)
)
source.config.deployment["sceneSha256"] = "other"
with self.assertRaisesRegex(ApiError, "不匹配"):
self.manager.parse_config(self.payload(mobileParams=params | {"stage": "navigate"}))
def fake_command(self, config, _path=None):
# Real subprocess and lifecycle, deterministic stand-in only for expensive PPO/export.
script = self.root / "fake.py"
metadata = config.deployment | {"modelSha256": hashlib.sha256(b"onnx").hexdigest()}
script.write_text(
"import pathlib,sys,json,time\n"
"out=pathlib.Path(sys.argv[sys.argv.index('--output')+1])\n"
"print('Learning iteration 1 / 2',flush=True)\n"
"print('Mean surrogate loss: -0.25',flush=True)\n"
"time.sleep(.05)\n"
"out.write_bytes(b'onnx')\n"
f"out.with_name('deployment.json').write_text({json.dumps(json.dumps(metadata))})\n"
)
return [sys.executable, "-u", str(script)]
def test_http_create_poll_download_and_failures(self):
manager = self.manager
class Handler(TrainingRequestHandler):
access_token = "test-token"
def log_message(self, *_args):
pass
Handler.manager = manager
http = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=http.serve_forever, daemon=True)
thread.start()
base = f"http://127.0.0.1:{http.server_port}"
def request(path, body=None, content_type="application/json", token="test-token"):
req = Request(
base + path,
data=body,
headers={"Authorization": f"Bearer {token}", "Content-Type": content_type},
)
return urlopen(req, timeout=5)
try:
with self.assertRaises(HTTPError) as error:
request(
"/api/training/mobile-packages", archive(), "application/zip", token="wrong"
)
self.assertEqual(error.exception.code, 401)
error.exception.close()
with request("/api/training/mobile-packages", archive(), "application/zip") as response:
self.assertEqual(json.load(response)["id"], self.package["id"])
with patch.object(manager, "command_for", side_effect=self.fake_command):
with request("/api/training/jobs", json.dumps(self.payload()).encode()) as response:
self.assertEqual(response.status, 202)
job = json.load(response)
for _ in range(100):
with request("/api/training/jobs/" + job["id"]) as response:
job = json.load(response)
if job["state"] not in ("queued", "running"):
break
time.sleep(0.02)
self.assertEqual(job["state"], "succeeded", job)
self.assertEqual(job["progress"], 1)
self.assertIn("Mean surrogate loss: -0.25", job["logs"])
for filename in ("policy.onnx", "deployment.json"):
with request(f"/api/training/jobs/{job['id']}/artifacts/{filename}") as response:
self.assertTrue(response.read())
self.assertIsNone(manager.lease.public())
failed = TrainingJob(id="f" * 32, config=manager.parse_config(self.payload()))
with patch.object(
manager, "command_for", return_value=[sys.executable, "-c", "raise SystemExit(7)"]
):
manager._run(failed)
self.assertEqual(failed.state, "failed")
self.assertIsNone(failed.artifact)
finally:
http.shutdown()
http.server_close()
thread.join()
def test_cancel_before_launch_and_progress_parser(self):
job = TrainingJob(id="c" * 32, config=self.manager.parse_config(self.payload()))
self.manager._update_from_log(job, "\x1b[32mLearning iteration 1 / 2\x1b[0m")
self.assertEqual(job.public()["progress"], 0.5)
job.cancel_requested = True
with patch("server.subprocess.Popen") as popen:
self.manager._run(job)
popen.assert_not_called()
self.assertEqual(job.state, "cancelled")