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
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:
@@ -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")
|
||||
Reference in New Issue
Block a user