mru-cli 0.1.2__py3-none-any.whl
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.
- mru_cli/__init__.py +1 -0
- mru_cli/__main__.py +3 -0
- mru_cli/main.py +377 -0
- mru_cli/manifest.py +84 -0
- mru_cli/modal_inference.py +122 -0
- mru_cli/project.py +92 -0
- mru_cli/targets.py +106 -0
- mru_cli-0.1.2.dist-info/METADATA +92 -0
- mru_cli-0.1.2.dist-info/RECORD +12 -0
- mru_cli-0.1.2.dist-info/WHEEL +5 -0
- mru_cli-0.1.2.dist-info/entry_points.txt +2 -0
- mru_cli-0.1.2.dist-info/top_level.txt +1 -0
mru_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MRU terminal client."""
|
mru_cli/__main__.py
ADDED
mru_cli/main.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Terminal deployment flow for the MRU-owned physical bottle cell."""
|
|
2
|
+
import argparse
|
|
3
|
+
import getpass
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from urllib.error import HTTPError, URLError
|
|
12
|
+
from urllib.parse import quote, urlparse
|
|
13
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
from .manifest import checkpoint_digest
|
|
17
|
+
from .project import init_project, load_project
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CliError(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NoRedirect(HTTPRedirectHandler):
|
|
25
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def checked_url(url):
|
|
30
|
+
parsed = urlparse(url)
|
|
31
|
+
if (not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment
|
|
32
|
+
or parsed.path not in ("", "/")
|
|
33
|
+
or not (parsed.scheme == "https" or parsed.scheme == "http" and parsed.hostname in ("localhost", "127.0.0.1", "::1"))):
|
|
34
|
+
raise CliError("Use an HTTPS MRU website origin (HTTP is allowed only for localhost)")
|
|
35
|
+
return url.rstrip("/")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Client:
|
|
39
|
+
def __init__(self, url, token):
|
|
40
|
+
self.url = checked_url(url)
|
|
41
|
+
if not token.startswith("mru_cli_"):
|
|
42
|
+
raise CliError("Create a CLI token in the Deployments dashboard, then run mru login")
|
|
43
|
+
self.token = token
|
|
44
|
+
|
|
45
|
+
def request(self, path, body=None):
|
|
46
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
47
|
+
request = Request(self.url + "/api/cli" + path, data=data,
|
|
48
|
+
headers={"Authorization": "Bearer " + self.token, "Content-Type": "application/json"})
|
|
49
|
+
try:
|
|
50
|
+
with build_opener(NoRedirect).open(request, timeout=30) as response:
|
|
51
|
+
return json.load(response)
|
|
52
|
+
except HTTPError as exc:
|
|
53
|
+
try:
|
|
54
|
+
detail = json.load(exc).get("detail", f"HTTP {exc.code}")
|
|
55
|
+
except (ValueError, AttributeError):
|
|
56
|
+
detail = f"HTTP {exc.code}"
|
|
57
|
+
raise CliError(str(detail)) from exc
|
|
58
|
+
except (URLError, TimeoutError) as exc:
|
|
59
|
+
raise CliError("MRU connection failed; a submitted request may have been accepted. Reuse its request ID when retrying.") from exc
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def config_path():
|
|
63
|
+
return Path(os.environ.get("MRU_CONFIG", str(Path.home() / ".config/mru/config.json")))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def read_config():
|
|
67
|
+
path = config_path()
|
|
68
|
+
return json.loads(path.read_text()) if path.exists() else {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def save_config(value):
|
|
72
|
+
path = config_path()
|
|
73
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
74
|
+
descriptor, temporary = tempfile.mkstemp(dir=path.parent, prefix=".mru-")
|
|
75
|
+
try:
|
|
76
|
+
with os.fdopen(descriptor, "w") as handle:
|
|
77
|
+
json.dump(value, handle)
|
|
78
|
+
os.replace(temporary, path)
|
|
79
|
+
finally:
|
|
80
|
+
Path(temporary).unlink(missing_ok=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def segment(value):
|
|
84
|
+
return quote(value, safe="")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def publish_inference(manifest, local_checkpoint=None):
|
|
88
|
+
import modal
|
|
89
|
+
if local_checkpoint:
|
|
90
|
+
print("Uploading checkpoint and processors…", file=sys.stderr)
|
|
91
|
+
volume = modal.Volume.from_name(manifest["volume"])
|
|
92
|
+
# The destination is derived from the contents, never a user's training path.
|
|
93
|
+
with volume.batch_upload(force=True) as batch:
|
|
94
|
+
batch.put_directory(str(local_checkpoint), manifest["checkpoint"].removeprefix("/store"))
|
|
95
|
+
if checkpoint_digest(local_checkpoint) != manifest["checkpointDigest"]:
|
|
96
|
+
raise CliError("Checkpoint changed during upload; save a stable checkpoint and deploy again")
|
|
97
|
+
with tempfile.TemporaryDirectory(prefix="mru-deploy-") as directory:
|
|
98
|
+
path = Path(directory) / "manifest.json"
|
|
99
|
+
path.write_text(json.dumps(manifest))
|
|
100
|
+
result = subprocess.run([sys.executable, "-m", "modal", "deploy", "-m",
|
|
101
|
+
"mru_cli.modal_inference"],
|
|
102
|
+
env={**os.environ, "MRU_BOTTLE_MANIFEST": str(path)}, stdout=sys.stderr, stderr=sys.stderr)
|
|
103
|
+
if result.returncode:
|
|
104
|
+
raise CliError("Inference publication failed. The deployment remains resumable; rerun the same deploy command.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def wait_for(client, identifier, timeout):
|
|
108
|
+
deadline = time.monotonic() + timeout
|
|
109
|
+
previous = None
|
|
110
|
+
deployment = identifier.startswith("deployment:")
|
|
111
|
+
path = "/v1/" + ("deployments/" if deployment else "evaluations/") + segment(identifier)
|
|
112
|
+
while True:
|
|
113
|
+
result = client.request(path)
|
|
114
|
+
state = result["deployment"]["status"] if deployment else result["summary"]["status"]
|
|
115
|
+
message = result["deployment"].get("message", "") if deployment else next(
|
|
116
|
+
(item["run"].get("provenance", {}).get("message", "") for item in result.get("runs", [])), "")
|
|
117
|
+
activity = (state, message)
|
|
118
|
+
if activity != previous:
|
|
119
|
+
print(state.replace("_", " ") + (" · " + message if message else ""), file=sys.stderr)
|
|
120
|
+
previous = activity
|
|
121
|
+
if state in {"deployed", "completed", "failed", "cancelled"}:
|
|
122
|
+
return result
|
|
123
|
+
if time.monotonic() >= deadline:
|
|
124
|
+
raise CliError(f"Stopped waiting; the job is unchanged. Resume with: mru status {identifier} --watch")
|
|
125
|
+
time.sleep(3)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def output(value, as_json):
|
|
129
|
+
if as_json:
|
|
130
|
+
print(json.dumps(value, indent=2))
|
|
131
|
+
elif value.get("target") == "local" and "box" in value:
|
|
132
|
+
box = value["box"]
|
|
133
|
+
active = next((model for model in box.get("deployments", []) if model["id"] == box.get("active_id")), {})
|
|
134
|
+
print("Ethernet box: " + ("running" if box.get("running") else "stopped"))
|
|
135
|
+
print("Loaded model: " + active.get("name", "none"))
|
|
136
|
+
print("Robot contract: " + str(active.get("state_dim", "?")) + " state / " + str(active.get("action_dim", "?")) + " action values")
|
|
137
|
+
operation = value.get("operation") or {}
|
|
138
|
+
if operation:
|
|
139
|
+
progress = "Staging: " + operation.get("phase", "unknown").replace("_", " ")
|
|
140
|
+
if operation.get("totalBytes"):
|
|
141
|
+
progress += f" · {operation.get('receivedBytes', 0) / 1024**3:.2f} / {operation['totalBytes'] / 1024**3:.2f} GiB"
|
|
142
|
+
print(progress)
|
|
143
|
+
print("Client: " + ("ready" if value["client"]["ready"] else value["client"]["error"]))
|
|
144
|
+
elif "deployment" in value:
|
|
145
|
+
doc = value["deployment"]
|
|
146
|
+
print(f"{doc['id']} {doc['name']}\n{doc['status'].replace('_', ' ')} · {doc['message']}")
|
|
147
|
+
if "robot" in value:
|
|
148
|
+
print("Robot: " + value["robot"]["reason"])
|
|
149
|
+
elif "evaluation" in value:
|
|
150
|
+
print(f"{value['evaluation']['id']} {value['summary']['status']}")
|
|
151
|
+
for run in value["runs"]:
|
|
152
|
+
print(run["run"].get("provenance", {}).get("message", "Task outcome remains unverified"))
|
|
153
|
+
elif "deployments" in value:
|
|
154
|
+
for doc in value["deployments"]:
|
|
155
|
+
print(f"{doc['id']} {doc['status']:20} {doc['name']}")
|
|
156
|
+
if not value["deployments"]:
|
|
157
|
+
print("No deployments yet. Use mru deploy mru.json")
|
|
158
|
+
print("Robot: " + value["robot"]["reason"])
|
|
159
|
+
else:
|
|
160
|
+
print(json.dumps(value, indent=2))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def parser():
|
|
164
|
+
root = argparse.ArgumentParser(description=__doc__)
|
|
165
|
+
root.add_argument("--url", help="MRU website origin; defaults to MRU_URL or saved login")
|
|
166
|
+
root.add_argument("--json", action="store_true", help="Print machine-readable results")
|
|
167
|
+
commands = root.add_subparsers(dest="command", required=True)
|
|
168
|
+
commands.add_parser("login", help="Save a dashboard CLI token using a hidden prompt")
|
|
169
|
+
commands.add_parser("logout", help="Remove saved local credentials")
|
|
170
|
+
commands.add_parser("deployments", help="List checkpoint releases")
|
|
171
|
+
target = commands.add_parser("target", help="Select Modal or Ethernet inference for new deployments")
|
|
172
|
+
targets = target.add_subparsers(dest="target_command", required=True)
|
|
173
|
+
use = targets.add_parser("use", help="Save the default inference target on this computer")
|
|
174
|
+
use.add_argument("name", choices=("modal", "local"))
|
|
175
|
+
use.add_argument("--relay", help="Devbox relay origin, reached over Tailscale")
|
|
176
|
+
targets.add_parser("show", help="Show this computer's default target")
|
|
177
|
+
targets.add_parser("status", help="Inspect the Ethernet inference box through the Devbox")
|
|
178
|
+
targets.add_parser("logs", help="Show Ethernet inference worker logs")
|
|
179
|
+
upload = targets.add_parser("upload", help="Upload a checkpoint and run GPU-box warm inference without robot execution")
|
|
180
|
+
upload.add_argument("checkpoint", type=Path)
|
|
181
|
+
upload.add_argument("--name", help="Name in the Ethernet box model library")
|
|
182
|
+
pull = targets.add_parser("pull", help="Load an owned Modal release's checkpoint on the Ethernet box without a Mac upload")
|
|
183
|
+
pull.add_argument("deployment")
|
|
184
|
+
pull.add_argument("--name", help="Name in the Ethernet box model library")
|
|
185
|
+
init = commands.add_parser("init", help="Create a standalone robot job folder")
|
|
186
|
+
init.add_argument("directory", type=Path)
|
|
187
|
+
init.add_argument("--type", choices=("act", "pi05"), default="act", dest="policy_type")
|
|
188
|
+
init.add_argument("--volume", default="", help="Existing Modal checkpoint volume")
|
|
189
|
+
init.add_argument("--runtime-image", default="", help="Validated Modal runtime image ID")
|
|
190
|
+
init.add_argument("--rate-hz", type=int, choices=(30, 50), default=30)
|
|
191
|
+
check = commands.add_parser("check", help="Validate job settings and hash the checkpoint locally")
|
|
192
|
+
check.add_argument("manifest", type=Path, nargs="?", default=Path("."), help="Job folder or JSON manifest")
|
|
193
|
+
check.add_argument("--checkpoint", type=Path, help="Override the local checkpoint directory")
|
|
194
|
+
check.add_argument("--target", choices=("modal", "local"))
|
|
195
|
+
check.add_argument("--task", help="Exact training instruction for local pi05")
|
|
196
|
+
digest = commands.add_parser("digest", help="Hash a saved checkpoint, including processors")
|
|
197
|
+
digest.add_argument("directory", type=Path)
|
|
198
|
+
deploy = commands.add_parser("deploy", help="Upload, validate and queue a physical robot task")
|
|
199
|
+
deploy.add_argument("manifest", type=Path, nargs="?", default=Path("."), help="Job folder or physical inference JSON manifest (default: current folder)")
|
|
200
|
+
deploy.add_argument("--checkpoint", type=Path, help="Upload a complete saved local checkpoint")
|
|
201
|
+
deploy.add_argument("--target", choices=("modal", "local"), help="Override mru target use for this deployment")
|
|
202
|
+
deploy.add_argument("--task", help="Exact training instruction for local pi05")
|
|
203
|
+
deploy.add_argument("--wait", action="store_true", help="Watch the robot task through completion")
|
|
204
|
+
deploy.add_argument("--no-run", action="store_true", help="Publish and validate only; do not queue robot execution")
|
|
205
|
+
deploy.add_argument("--auto", action="store_true", help="Authorize this job to execute without a local AUTO prompt")
|
|
206
|
+
deploy.add_argument("--request-id", help="Reuse after an uncertain deployment submission to avoid a duplicate task")
|
|
207
|
+
validate = commands.add_parser("validate", help="Retry loading an already published release")
|
|
208
|
+
validate.add_argument("identifier")
|
|
209
|
+
run = commands.add_parser("run", help="Queue another robot task from a published release")
|
|
210
|
+
run.add_argument("identifier")
|
|
211
|
+
run.add_argument("--request-id", help="Reuse this value after an uncertain submission")
|
|
212
|
+
run.add_argument("--wait", action="store_true")
|
|
213
|
+
run.add_argument("--auto", action="store_true", help="Authorize this job to execute without a local AUTO prompt")
|
|
214
|
+
status = commands.add_parser("status", help="Inspect a deployment or evaluation")
|
|
215
|
+
status.add_argument("identifier")
|
|
216
|
+
status.add_argument("--watch", action="store_true")
|
|
217
|
+
logs = commands.add_parser("logs", help="Show persisted deployment activity and run messages")
|
|
218
|
+
logs.add_argument("identifier")
|
|
219
|
+
cancel = commands.add_parser("cancel", help="Request stop for an evaluation; local stop confirmation is still required")
|
|
220
|
+
cancel.add_argument("identifier")
|
|
221
|
+
for command in (deploy, run, status):
|
|
222
|
+
command.add_argument("--timeout", type=int, default=900, help="Maximum wait in seconds")
|
|
223
|
+
return root
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def execute(args):
|
|
227
|
+
config = read_config()
|
|
228
|
+
if args.command == "target" and args.target_command in {"use", "show"}:
|
|
229
|
+
from .targets import DEFAULT_RELAY, relay_origin
|
|
230
|
+
if args.target_command == "use":
|
|
231
|
+
config["inferenceTarget"] = args.name
|
|
232
|
+
if args.relay:
|
|
233
|
+
config["localRelay"] = relay_origin(args.relay)
|
|
234
|
+
save_config(config)
|
|
235
|
+
print(json.dumps({"target": config.get("inferenceTarget", "modal"),
|
|
236
|
+
"localRelay": config.get("localRelay", DEFAULT_RELAY)}))
|
|
237
|
+
return 0
|
|
238
|
+
if args.command == "deploy" and args.auto and args.no_run:
|
|
239
|
+
raise CliError("--auto cannot be combined with --no-run")
|
|
240
|
+
if args.command == "init":
|
|
241
|
+
directory = init_project(args.directory, policy_type=args.policy_type, volume=args.volume,
|
|
242
|
+
runtime_image=args.runtime_image, rate_hz=args.rate_hz)
|
|
243
|
+
if args.json:
|
|
244
|
+
print(json.dumps({"directory": str(directory), "manifest": str(directory / "mru.json")}))
|
|
245
|
+
else:
|
|
246
|
+
print(f"Created robot job at {directory}\nAdd your saved checkpoint to checkpoint/ and configure mru.json.\nThen run mru check from the job folder.")
|
|
247
|
+
return 0
|
|
248
|
+
if args.command == "check":
|
|
249
|
+
manifest, checkpoint = load_project(args.manifest, args.checkpoint,
|
|
250
|
+
target=args.target or config.get("inferenceTarget"), task=args.task)
|
|
251
|
+
if args.json:
|
|
252
|
+
print(json.dumps({"manifest": manifest, "localCheckpoint": str(checkpoint.resolve()) if checkpoint else None}, indent=2))
|
|
253
|
+
else:
|
|
254
|
+
print(f"Job settings valid: {manifest['name']} ({manifest['type']})\nCheckpoint SHA-256: {manifest['checkpointDigest']}\nLocal checks only; model loading and robot compatibility still require deployment validation.")
|
|
255
|
+
return 0
|
|
256
|
+
if args.command == "digest":
|
|
257
|
+
print(checkpoint_digest(args.directory))
|
|
258
|
+
return 0
|
|
259
|
+
if args.command == "logout":
|
|
260
|
+
config_path().unlink(missing_ok=True)
|
|
261
|
+
print("Local credentials removed. Revoke the token in the dashboard to invalidate it.")
|
|
262
|
+
return 0
|
|
263
|
+
url = args.url or os.environ.get("MRU_URL") or config.get("url", "https://app.makerobotsuseful.com")
|
|
264
|
+
token = os.environ.get("MRU_TOKEN") or config.get("token", "")
|
|
265
|
+
if args.command == "login":
|
|
266
|
+
token = getpass.getpass("CLI token from the Deployments dashboard: ").strip()
|
|
267
|
+
client = Client(url, token)
|
|
268
|
+
if args.command == "login":
|
|
269
|
+
client.request("/v1/me")
|
|
270
|
+
save_config({**config, "url": client.url, "token": token})
|
|
271
|
+
print("Connected to " + client.url)
|
|
272
|
+
return 0
|
|
273
|
+
if args.command == "deploy":
|
|
274
|
+
manifest, checkpoint = load_project(args.manifest, args.checkpoint,
|
|
275
|
+
target=args.target or config.get("inferenceTarget"), task=args.task)
|
|
276
|
+
local = manifest.get("inferenceTarget") == "local"
|
|
277
|
+
if local:
|
|
278
|
+
from .targets import LocalTarget, DEFAULT_RELAY
|
|
279
|
+
local_target = LocalTarget(token, config.get("localRelay", DEFAULT_RELAY))
|
|
280
|
+
readiness = local_target.request("/v1/status")["client"]
|
|
281
|
+
if not readiness["ready"]:
|
|
282
|
+
raise CliError("Local inference client is not ready: " + readiness["error"])
|
|
283
|
+
if checkpoint is None and not local_target.request("/v1/staged/" + manifest["checkpointDigest"])["available"]:
|
|
284
|
+
raise CliError("Checkpoint is not staged on the Ethernet box; use --checkpoint or mru target pull first")
|
|
285
|
+
request_id = args.request_id or uuid.uuid4().hex
|
|
286
|
+
if not args.no_run:
|
|
287
|
+
print(f"Request ID: {request_id} (reuse with --request-id when retrying this deploy)", file=sys.stderr)
|
|
288
|
+
print("This job is authorized to start physical execution when the robot is ready." if args.auto else
|
|
289
|
+
"This deployment queues a physical task; it runs when local automatic mode is enabled.", file=sys.stderr)
|
|
290
|
+
result = client.request("/v1/deployments", manifest)
|
|
291
|
+
doc = result["deployment"]
|
|
292
|
+
identifier = doc["id"]
|
|
293
|
+
print(identifier, file=sys.stderr)
|
|
294
|
+
print(client.url + "/deployments?deployment=" + segment(identifier), file=sys.stderr)
|
|
295
|
+
if not local and doc["status"] not in {"deployed", "validating"}:
|
|
296
|
+
publish_inference(doc["manifest"], checkpoint)
|
|
297
|
+
result = client.request("/v1/deployments/" + segment(identifier) + "/validate", {})
|
|
298
|
+
if local and result["deployment"]["status"] != "deployed":
|
|
299
|
+
print("Publishing checkpoint to the Ethernet GPU box through the resident Devbox relay…", file=sys.stderr)
|
|
300
|
+
local_target.publish(identifier, checkpoint, manifest["checkpointDigest"])
|
|
301
|
+
result = client.request("/v1/deployments/" + segment(identifier))
|
|
302
|
+
if args.wait or not args.no_run:
|
|
303
|
+
result = wait_for(client, identifier, args.timeout)
|
|
304
|
+
if not args.no_run and result["deployment"]["status"] == "deployed":
|
|
305
|
+
result = client.request("/v1/deployments/" + segment(identifier) + "/runs",
|
|
306
|
+
{"requestId": request_id, **({"auto": True} if args.auto else {})})
|
|
307
|
+
print(result["evaluation"]["id"], file=sys.stderr)
|
|
308
|
+
if args.wait:
|
|
309
|
+
result = wait_for(client, result["evaluation"]["id"], args.timeout)
|
|
310
|
+
elif args.command == "target":
|
|
311
|
+
from .targets import LocalTarget, DEFAULT_RELAY
|
|
312
|
+
target = LocalTarget(token, config.get("localRelay", DEFAULT_RELAY))
|
|
313
|
+
if args.target_command == "upload":
|
|
314
|
+
result = target.stage(args.checkpoint, args.name)
|
|
315
|
+
elif args.target_command == "pull":
|
|
316
|
+
print("Devbox is fetching the saved checkpoint, then loading and warming the Ethernet GPU box…", file=sys.stderr)
|
|
317
|
+
print("Progress: mru target status", file=sys.stderr)
|
|
318
|
+
result = target.pull(args.deployment, args.name)
|
|
319
|
+
else:
|
|
320
|
+
result = target.request("/v1/" + args.target_command)
|
|
321
|
+
elif args.command == "deployments":
|
|
322
|
+
result = client.request("/v1/deployments")
|
|
323
|
+
elif args.command == "validate":
|
|
324
|
+
result = client.request("/v1/deployments/" + segment(args.identifier) + "/validate", {})
|
|
325
|
+
doc = result["deployment"]
|
|
326
|
+
if doc["manifest"].get("inferenceTarget") == "local" and doc["status"] != "deployed":
|
|
327
|
+
from .targets import LocalTarget, DEFAULT_RELAY
|
|
328
|
+
target = LocalTarget(token, config.get("localRelay", DEFAULT_RELAY))
|
|
329
|
+
target.publish(args.identifier, None, doc["manifest"]["checkpointDigest"])
|
|
330
|
+
result = client.request("/v1/deployments/" + segment(args.identifier))
|
|
331
|
+
elif args.command == "run":
|
|
332
|
+
request_id = args.request_id or uuid.uuid4().hex
|
|
333
|
+
print(f"Request ID: {request_id} (reuse with --request-id if the response is lost)", file=sys.stderr)
|
|
334
|
+
if args.auto:
|
|
335
|
+
print("This job is authorized to start physical execution when the robot is ready.", file=sys.stderr)
|
|
336
|
+
result = client.request("/v1/deployments/" + segment(args.identifier) + "/runs",
|
|
337
|
+
{"requestId": request_id, **({"auto": True} if args.auto else {})})
|
|
338
|
+
if args.wait:
|
|
339
|
+
print(result["evaluation"]["id"], file=sys.stderr)
|
|
340
|
+
result = wait_for(client, result["evaluation"]["id"], args.timeout)
|
|
341
|
+
elif args.command == "cancel":
|
|
342
|
+
result = client.request("/v1/evaluations/" + segment(args.identifier) + "/cancel", {})
|
|
343
|
+
else:
|
|
344
|
+
if args.command == "status" and args.watch:
|
|
345
|
+
result = wait_for(client, args.identifier, args.timeout)
|
|
346
|
+
else:
|
|
347
|
+
resource = "deployments" if args.identifier.startswith("deployment:") else "evaluations"
|
|
348
|
+
result = client.request(f"/v1/{resource}/" + segment(args.identifier))
|
|
349
|
+
if args.command == "logs":
|
|
350
|
+
entries = list(result.get("deployment", {}).get("events", []))
|
|
351
|
+
evaluations = result.get("evaluations", [result] if "evaluation" in result else [])
|
|
352
|
+
for view in evaluations:
|
|
353
|
+
for item in view["runs"]:
|
|
354
|
+
run = item["run"]
|
|
355
|
+
entries.append({"at": run.get("endedAt") or run["createdAt"],
|
|
356
|
+
"message": run["status"] + " · " + run.get("provenance", {}).get("message", "Task outcome unverified")})
|
|
357
|
+
if args.json:
|
|
358
|
+
print(json.dumps(entries, indent=2))
|
|
359
|
+
else:
|
|
360
|
+
for entry in entries:
|
|
361
|
+
print(f"{entry['at']} {entry['message']}")
|
|
362
|
+
return 0
|
|
363
|
+
output(result, args.json)
|
|
364
|
+
status = result.get("deployment", {}).get("status") or result.get("summary", {}).get("status")
|
|
365
|
+
return 1 if status in {"failed", "cancelled"} else 0
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def main(argv=None):
|
|
369
|
+
args = parser().parse_args(argv)
|
|
370
|
+
try:
|
|
371
|
+
return execute(args)
|
|
372
|
+
except (CliError, ValueError, OSError) as exc:
|
|
373
|
+
print(f"mru: {exc}", file=sys.stderr)
|
|
374
|
+
return 1
|
|
375
|
+
except KeyboardInterrupt:
|
|
376
|
+
print("Stopped waiting; inspect the deployment or run before submitting again.", file=sys.stderr)
|
|
377
|
+
return 130
|
mru_cli/manifest.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Shared, dependency-free contract for physical checkpoint releases."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path, PurePosixPath
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
DEPLOYMENT_PROTOCOL = "mru.physical-deployment/v1"
|
|
8
|
+
MAX_CYCLES = 30
|
|
9
|
+
JOINT_ORDER = ["finger_joint1", *[f"joint{i}" for i in range(1, 8)]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def require_policy_attestation(policy, reply):
|
|
13
|
+
if not isinstance(reply, dict):
|
|
14
|
+
raise ValueError("Inference attestation must be an object")
|
|
15
|
+
if policy.get("manifestDigest") and reply.get("manifestDigest") != policy["manifestDigest"]:
|
|
16
|
+
raise ValueError("Inference release does not match the deployed manifest")
|
|
17
|
+
if reply.get("checkpointDigest") != policy["checkpointDigest"] or reply.get("fps") != policy["rateHz"]:
|
|
18
|
+
raise ValueError("Inference checkpoint/control rate does not match assignment")
|
|
19
|
+
if reply.get("joint_order") != JOINT_ORDER:
|
|
20
|
+
raise ValueError("Inference joint ordering mismatch")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def canonical(value):
|
|
24
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def manifest_digest(manifest):
|
|
28
|
+
return hashlib.sha256(canonical(manifest).encode()).hexdigest()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def validate_manifest(value):
|
|
32
|
+
if not isinstance(value, dict):
|
|
33
|
+
raise ValueError("Manifest must be a JSON object")
|
|
34
|
+
local = value.get("inferenceTarget") == "local"
|
|
35
|
+
required = {"name", "type", "checkpoint", "checkpointDigest", "rateHz"}
|
|
36
|
+
required |= {"inferenceTarget", "task"} if local else {"volume", "runtimeImageId"}
|
|
37
|
+
if not required <= value.keys() or value.keys() - required - {"cycles"}:
|
|
38
|
+
raise ValueError("Manifest requires " + ", ".join(sorted(required)) + "; cycles is optional")
|
|
39
|
+
if not isinstance(value["name"], str) or not 1 <= len(value["name"].strip()) <= 80:
|
|
40
|
+
raise ValueError("name must contain 1–80 characters")
|
|
41
|
+
if value["type"] not in ("act", "pi05"):
|
|
42
|
+
raise ValueError("Only physical bottle ACT and pi05 checkpoints are supported")
|
|
43
|
+
if local and (value["type"] != "pi05" or not isinstance(value["task"], str)
|
|
44
|
+
or not 1 <= len(value["task"].strip()) <= 2000):
|
|
45
|
+
raise ValueError("The local target requires pi05 and its training task instruction")
|
|
46
|
+
patterns = [("checkpointDigest", r"[a-f0-9]{64}")]
|
|
47
|
+
if not local:
|
|
48
|
+
patterns += [("volume", r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}"), ("runtimeImageId", r"im-[A-Za-z0-9]+")]
|
|
49
|
+
for key, pattern in patterns:
|
|
50
|
+
if not isinstance(value[key], str) or not re.fullmatch(pattern, value[key]):
|
|
51
|
+
raise ValueError(f"Invalid {key}")
|
|
52
|
+
checkpoint = value["checkpoint"]
|
|
53
|
+
if (not isinstance(checkpoint, str) or not checkpoint.startswith("/store/")
|
|
54
|
+
or any(part in (".", "..") for part in checkpoint.split("/"))
|
|
55
|
+
or str(PurePosixPath(checkpoint)) != checkpoint or len(checkpoint) > 500):
|
|
56
|
+
raise ValueError("checkpoint must be a normalized directory under /store")
|
|
57
|
+
if type(value["rateHz"]) is not int or value["rateHz"] not in (30, 50):
|
|
58
|
+
raise ValueError("rateHz must be the checkpoint's learned rate: 30 or 50")
|
|
59
|
+
cycles = value.get("cycles", 5)
|
|
60
|
+
if type(cycles) is not int or not 1 <= cycles <= MAX_CYCLES:
|
|
61
|
+
raise ValueError(f"cycles must be between 1 and {MAX_CYCLES}")
|
|
62
|
+
return {**value, "name": value["name"].strip(), "cycles": cycles}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def checkpoint_digest(directory):
|
|
66
|
+
directory = Path(directory)
|
|
67
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
68
|
+
raise ValueError("Checkpoint must be a real directory")
|
|
69
|
+
result = hashlib.sha256()
|
|
70
|
+
files = 0
|
|
71
|
+
for path in sorted(directory.rglob("*")):
|
|
72
|
+
if path.is_symlink():
|
|
73
|
+
raise ValueError("Checkpoint symlinks are not supported")
|
|
74
|
+
if not path.is_file():
|
|
75
|
+
continue
|
|
76
|
+
files += 1
|
|
77
|
+
digest = hashlib.sha256()
|
|
78
|
+
with path.open("rb") as handle:
|
|
79
|
+
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
|
80
|
+
digest.update(block)
|
|
81
|
+
result.update(path.relative_to(directory).as_posix().encode() + b"\0" + digest.digest())
|
|
82
|
+
if not files:
|
|
83
|
+
raise ValueError("Checkpoint directory is empty")
|
|
84
|
+
return result.hexdigest()
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Deploy a separately pinned bottle checkpoint; returns predictions only.
|
|
2
|
+
|
|
3
|
+
Set MRU_BOTTLE_MANIFEST to an immutable local JSON manifest before modal deploy.
|
|
4
|
+
This app has no device credential, robot connection or training entrypoint.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import modal
|
|
11
|
+
from mru_cli.manifest import DEPLOYMENT_PROTOCOL, checkpoint_digest, manifest_digest
|
|
12
|
+
|
|
13
|
+
MANIFEST_PATH = Path(os.environ.get("MRU_BOTTLE_MANIFEST", "/opt/bottle-manifest.json"))
|
|
14
|
+
MANIFEST = json.loads(MANIFEST_PATH.read_text())
|
|
15
|
+
if MANIFEST["type"] not in {"act", "pi05"} or MANIFEST["rateHz"] not in (30, 50):
|
|
16
|
+
raise ValueError("Manifest requires ACT/pi05 and exact learned control rate")
|
|
17
|
+
app = modal.App(MANIFEST["modalApp"])
|
|
18
|
+
volume = modal.Volume.from_name(MANIFEST["volume"])
|
|
19
|
+
image = (modal.Image.from_id(MANIFEST["runtimeImageId"])
|
|
20
|
+
.env({"TOKENIZERS_PARALLELISM": "false"})
|
|
21
|
+
.add_local_python_source("mru_cli", copy=True)
|
|
22
|
+
.add_local_file(MANIFEST_PATH, "/opt/bottle-manifest.json", copy=True))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.cls(image=image, gpu="L4", cpu=4 if MANIFEST["type"] == "pi05" else 2,
|
|
26
|
+
memory=32768 if MANIFEST["type"] == "pi05" else 16384, volumes={"/store": volume},
|
|
27
|
+
secrets=[modal.Secret.from_name("huggingface-secret")],
|
|
28
|
+
timeout=600 if MANIFEST["type"] == "pi05" else 300, scaledown_window=300, max_containers=1,
|
|
29
|
+
retries=0)
|
|
30
|
+
class Predictor:
|
|
31
|
+
@modal.enter()
|
|
32
|
+
def load(self):
|
|
33
|
+
import site
|
|
34
|
+
site.addsitedir("/opt/anvil/.venv/lib/python3.12/site-packages")
|
|
35
|
+
import torch
|
|
36
|
+
from safetensors.torch import load_file
|
|
37
|
+
from lerobot.configs.policies import PreTrainedConfig
|
|
38
|
+
from lerobot.processor import PolicyProcessorPipeline
|
|
39
|
+
|
|
40
|
+
volume.reload()
|
|
41
|
+
checkpoint = Path(MANIFEST["checkpoint"])
|
|
42
|
+
if not checkpoint.is_relative_to("/store") or not checkpoint.is_dir():
|
|
43
|
+
raise ValueError("Checkpoint must be a saved directory on the mounted volume")
|
|
44
|
+
if checkpoint_digest(checkpoint) != MANIFEST["checkpointDigest"]:
|
|
45
|
+
raise ValueError("Checkpoint/processor digest does not match the pinned manifest")
|
|
46
|
+
# LeRobot registers each policy's config when its module is imported.
|
|
47
|
+
# Register the selected family before decoding the saved config.
|
|
48
|
+
if MANIFEST["type"] == "pi05":
|
|
49
|
+
import lerobot.policies.pi05.configuration_pi05
|
|
50
|
+
else:
|
|
51
|
+
import lerobot.policies.act.configuration_act
|
|
52
|
+
config = PreTrainedConfig.from_pretrained(checkpoint)
|
|
53
|
+
if config.type != MANIFEST["type"]:
|
|
54
|
+
raise ValueError("Checkpoint policy type mismatch")
|
|
55
|
+
config.device = "cuda"
|
|
56
|
+
if config.type == "pi05":
|
|
57
|
+
from lerobot.policies.pi05 import PI05Policy
|
|
58
|
+
import lerobot.policies.pi05.processor_pi05
|
|
59
|
+
config.compile_model = False
|
|
60
|
+
names = ["right_finger_joint1", *[f"right_joint{i}" for i in range(1, 8)]]
|
|
61
|
+
if config.action_feature_names != names or config.use_relative_actions:
|
|
62
|
+
raise ValueError("PI05 must emit absolute right-arm positions in gripper-first order")
|
|
63
|
+
self.model = PI05Policy(config)
|
|
64
|
+
weights = load_file(str(checkpoint / "model.safetensors"))
|
|
65
|
+
weights = self.model._fix_pytorch_state_dict_keys(weights, config)
|
|
66
|
+
weights = {key if key.startswith("model.") else "model." + key: value for key, value in weights.items()}
|
|
67
|
+
else:
|
|
68
|
+
from lerobot.policies.act.modeling_act import ACTPolicy
|
|
69
|
+
import lerobot.policies.act.processor_act
|
|
70
|
+
self.model = ACTPolicy(config)
|
|
71
|
+
weights = load_file(str(checkpoint / "model.safetensors"))
|
|
72
|
+
self.model.load_state_dict(weights, strict=True)
|
|
73
|
+
del weights
|
|
74
|
+
self.model = self.model.to("cuda").eval()
|
|
75
|
+
self.pre = PolicyProcessorPipeline.from_pretrained(checkpoint, config_filename="policy_preprocessor.json")
|
|
76
|
+
self.post = PolicyProcessorPipeline.from_pretrained(checkpoint, config_filename="policy_postprocessor.json")
|
|
77
|
+
self.image_shapes = {key: feature.shape for key, feature in config.input_features.items() if key.startswith("observation.images.")}
|
|
78
|
+
expected = {"observation.images." + name for name in ("waist", "wrist_r", "chest")}
|
|
79
|
+
if set(self.image_shapes) != expected or tuple(config.output_features["action"].shape) != (8,):
|
|
80
|
+
raise ValueError("Checkpoint must match the physical bottle camera/eight-joint contract")
|
|
81
|
+
|
|
82
|
+
@modal.method()
|
|
83
|
+
def describe(self):
|
|
84
|
+
return {"checkpointDigest": MANIFEST["checkpointDigest"], "fps": MANIFEST["rateHz"],
|
|
85
|
+
"manifestDigest": manifest_digest(MANIFEST), "deploymentProtocol": DEPLOYMENT_PROTOCOL,
|
|
86
|
+
"joint_order": ["finger_joint1", *[f"joint{i}" for i in range(1, 8)]],
|
|
87
|
+
"checkpoint": MANIFEST["checkpoint"], "task_outcome": "unverified"}
|
|
88
|
+
|
|
89
|
+
@modal.method()
|
|
90
|
+
def predict(self, state: list[float], images: dict[str, bytes]):
|
|
91
|
+
import io
|
|
92
|
+
import time
|
|
93
|
+
import numpy as np
|
|
94
|
+
import torch
|
|
95
|
+
from PIL import Image, ImageOps
|
|
96
|
+
|
|
97
|
+
start = time.perf_counter()
|
|
98
|
+
array = np.asarray(state, dtype=np.float32)
|
|
99
|
+
if array.shape != (8,) or not np.isfinite(array).all() or set(images) != {"waist", "wrist_r", "chest"}:
|
|
100
|
+
raise ValueError("Expected eight finite gripper-first positions and three bottle cameras")
|
|
101
|
+
obs = {"observation.state": torch.from_numpy(array), "task": ["Pick up the bottle and hold it above the table."]}
|
|
102
|
+
for name, data in images.items():
|
|
103
|
+
if len(data) > 2_000_000:
|
|
104
|
+
raise ValueError("Camera payload exceeds 2 MB")
|
|
105
|
+
_, height, width = self.image_shapes["observation.images." + name]
|
|
106
|
+
with Image.open(io.BytesIO(data)) as source:
|
|
107
|
+
rgb = ImageOps.pad(source.convert("RGB"), (width, height), method=Image.Resampling.BILINEAR)
|
|
108
|
+
image = np.asarray(rgb).copy()
|
|
109
|
+
obs["observation.images." + name] = torch.from_numpy(image).permute(2, 0, 1).float() / 255
|
|
110
|
+
with torch.inference_mode():
|
|
111
|
+
torch.manual_seed(42)
|
|
112
|
+
batch = self.pre(obs)
|
|
113
|
+
batch = {key: value.to("cuda") if torch.is_tensor(value) else value for key, value in batch.items()}
|
|
114
|
+
chunk = self.model.predict_action_chunk(batch)
|
|
115
|
+
actions = torch.stack([self.post.process_action(chunk[:, index]) for index in range(chunk.shape[1])], dim=1)
|
|
116
|
+
result = actions.squeeze(0).cpu().numpy()
|
|
117
|
+
if result.ndim != 2 or result.shape[1] != 8 or not 1 <= result.shape[0] <= 100 or not np.isfinite(result).all():
|
|
118
|
+
raise RuntimeError("Invalid physical action chunk")
|
|
119
|
+
return {"checkpointDigest": MANIFEST["checkpointDigest"], "fps": MANIFEST["rateHz"],
|
|
120
|
+
"manifestDigest": manifest_digest(MANIFEST),
|
|
121
|
+
"joint_order": ["finger_joint1", *[f"joint{i}" for i in range(1, 8)]],
|
|
122
|
+
"actions": result.tolist(), "inference_ms": (time.perf_counter() - start) * 1000}
|
mru_cli/project.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Portable job directories for the built-in physical policy runner."""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .manifest import checkpoint_digest, validate_manifest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
JOB_README = """# MRU robot job
|
|
9
|
+
|
|
10
|
+
This project uses MRU's built-in ACT/pi0.5 inference runner. You do not need to
|
|
11
|
+
write Python inference code. The current task is the supported OpenARM bottle
|
|
12
|
+
trial; custom tasks and inference scripts are not loaded from this folder.
|
|
13
|
+
|
|
14
|
+
1. Copy your complete saved LeRobot checkpoint into `checkpoint/`, including
|
|
15
|
+
`config.json`, `model.safetensors`, saved pre/postprocessors and their files.
|
|
16
|
+
2. Edit `mru.json`: set the existing Modal `volume` and validated
|
|
17
|
+
`runtimeImageId`, and match `type` and `rateHz` to your trained checkpoint.
|
|
18
|
+
`cycles` counts observe/predict/execute-chunk cycles within one trial.
|
|
19
|
+
3. From this folder, run:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
mru check .
|
|
23
|
+
mru login
|
|
24
|
+
mru deploy . --wait
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
You can also use `mru deploy /path/to/this-folder` from any directory.
|
|
28
|
+
The MRU CLI must already be installed, and Modal configured for the MRU workspace.
|
|
29
|
+
Local checks validate the manifest and hash files; model loading happens on Modal.
|
|
30
|
+
Deployment validates the checkpoint and queues its robot task. Once an operator
|
|
31
|
+
enables automatic mode locally at the workcell, uploaded jobs execute without
|
|
32
|
+
a per-job ARM prompt. Disabled/offline/busy workcells leave jobs in the queue.
|
|
33
|
+
Use `mru deploy . --no-run` to publish a release without requesting execution.
|
|
34
|
+
|
|
35
|
+
Only the configured checkpoint directory is uploaded. Source files, this README,
|
|
36
|
+
and other project files are not uploaded. Keep credentials out of the checkpoint.
|
|
37
|
+
`checkpointDir` is relative to `mru.json`; no absolute machine paths are needed.
|
|
38
|
+
The included `.gitignore` keeps weights and local secrets out of Git. Share the
|
|
39
|
+
project configuration through Git and supply checkpoint files separately.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def init_project(directory, *, policy_type="act", volume="", runtime_image="", rate_hz=30):
|
|
44
|
+
directory = Path(directory)
|
|
45
|
+
if directory.exists() and (not directory.is_dir() or any(directory.iterdir())):
|
|
46
|
+
raise ValueError("Choose a new or empty directory; mru init does not overwrite existing files")
|
|
47
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
manifest = {"name": directory.resolve().name, "type": policy_type,
|
|
49
|
+
"checkpointDir": "./checkpoint", "volume": volume,
|
|
50
|
+
"runtimeImageId": runtime_image, "rateHz": rate_hz, "cycles": 5}
|
|
51
|
+
(directory / "mru.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
|
52
|
+
(directory / "README.md").write_text(JOB_README)
|
|
53
|
+
(directory / ".gitignore").write_text("/checkpoint/\n.env\n.env.*\n.venv/\n__pycache__/\n.DS_Store\n")
|
|
54
|
+
(directory / "checkpoint").mkdir()
|
|
55
|
+
return directory.resolve()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_project(source, checkpoint_override=None, *, target=None, task=None):
|
|
59
|
+
source = Path(source)
|
|
60
|
+
manifest_path = source / "mru.json" if source.is_dir() else source
|
|
61
|
+
raw = json.loads(manifest_path.read_text())
|
|
62
|
+
if not isinstance(raw, dict):
|
|
63
|
+
raise ValueError("Manifest must be a JSON object")
|
|
64
|
+
if target is not None:
|
|
65
|
+
if target == "local":
|
|
66
|
+
raw["inferenceTarget"] = "local"
|
|
67
|
+
raw.pop("volume", None)
|
|
68
|
+
raw.pop("runtimeImageId", None)
|
|
69
|
+
elif target == "modal":
|
|
70
|
+
raw.pop("inferenceTarget", None)
|
|
71
|
+
raw.pop("task", None)
|
|
72
|
+
else:
|
|
73
|
+
raise ValueError("Inference target must be modal or local")
|
|
74
|
+
if task is not None:
|
|
75
|
+
raw["task"] = task
|
|
76
|
+
relative = raw.pop("checkpointDir", None)
|
|
77
|
+
checkpoint = Path(checkpoint_override) if checkpoint_override is not None else None
|
|
78
|
+
if relative is not None and checkpoint is None:
|
|
79
|
+
if not isinstance(relative, str) or not relative.strip():
|
|
80
|
+
raise ValueError("checkpointDir must name a directory inside the job folder")
|
|
81
|
+
path = Path(relative)
|
|
82
|
+
if path.is_absolute() or ".." in path.parts or not path.parts:
|
|
83
|
+
raise ValueError("checkpointDir must name a directory inside the job folder")
|
|
84
|
+
root = manifest_path.parent.resolve()
|
|
85
|
+
checkpoint = root / path
|
|
86
|
+
if root not in checkpoint.resolve().parents or any(
|
|
87
|
+
(root / Path(*path.parts[:index])).is_symlink() for index in range(1, len(path.parts) + 1)):
|
|
88
|
+
raise ValueError("checkpointDir must stay inside the job folder without symlinks")
|
|
89
|
+
if checkpoint is not None:
|
|
90
|
+
digest = checkpoint_digest(checkpoint)
|
|
91
|
+
raw.update(checkpointDigest=digest, checkpoint="/store/mru/checkpoints/" + digest)
|
|
92
|
+
return validate_manifest(raw), checkpoint
|
mru_cli/targets.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Terminal access to the resident Devbox's fixed Ethernet inference target."""
|
|
2
|
+
import http.client
|
|
3
|
+
import ipaddress
|
|
4
|
+
import json
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import tarfile
|
|
11
|
+
import tempfile
|
|
12
|
+
from urllib.parse import urlparse, quote
|
|
13
|
+
from .manifest import checkpoint_digest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_RELAY = "http://100.122.87.127:8092"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UploadStream:
|
|
20
|
+
def __init__(self, handle, total):
|
|
21
|
+
self.handle, self.total = handle, total
|
|
22
|
+
self.sent, self.last = 0, time.monotonic()
|
|
23
|
+
|
|
24
|
+
def read(self, size):
|
|
25
|
+
data = self.handle.read(size)
|
|
26
|
+
self.sent += len(data)
|
|
27
|
+
now = time.monotonic()
|
|
28
|
+
if now - self.last >= 15 or not data:
|
|
29
|
+
print(f"Uploaded {self.sent / 1024**3:.2f} / {self.total / 1024**3:.2f} GiB to Devbox", file=sys.stderr)
|
|
30
|
+
self.last = now
|
|
31
|
+
return data
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@contextmanager
|
|
35
|
+
def packed_checkpoint(checkpoint):
|
|
36
|
+
with tempfile.TemporaryDirectory(prefix="mru-local-") as directory:
|
|
37
|
+
archive = Path(directory) / "checkpoint.tar"
|
|
38
|
+
print("Packing checkpoint for Ethernet upload…", file=sys.stderr)
|
|
39
|
+
with tarfile.open(archive, "w", dereference=False) as output:
|
|
40
|
+
output.add(checkpoint, arcname="pretrained_model")
|
|
41
|
+
print(f"Uploading {archive.stat().st_size / 1024**3:.2f} GiB through the Devbox…", file=sys.stderr)
|
|
42
|
+
yield archive
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def relay_origin(value):
|
|
46
|
+
parsed = urlparse(value)
|
|
47
|
+
try:
|
|
48
|
+
address = ipaddress.ip_address(parsed.hostname or "")
|
|
49
|
+
trusted_http = address.is_loopback or address in ipaddress.ip_network("100.64.0.0/10")
|
|
50
|
+
except ValueError:
|
|
51
|
+
trusted_http = parsed.hostname == "localhost"
|
|
52
|
+
if (not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment
|
|
53
|
+
or parsed.path not in ("", "/") or parsed.scheme not in {"https", "http"}
|
|
54
|
+
or parsed.scheme == "http" and not trusted_http):
|
|
55
|
+
raise ValueError("Use an HTTPS relay, or HTTP over Tailscale/localhost")
|
|
56
|
+
return value.rstrip("/")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LocalTarget:
|
|
60
|
+
def __init__(self, token, url=DEFAULT_RELAY):
|
|
61
|
+
self.url, self.token = relay_origin(url), token
|
|
62
|
+
|
|
63
|
+
def request(self, path, archive=None, body=None):
|
|
64
|
+
parsed = urlparse(self.url)
|
|
65
|
+
connection_type = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
|
|
66
|
+
connection = connection_type(parsed.hostname, parsed.port, timeout=7200 if archive or body is not None else 30, blocksize=1024 * 1024)
|
|
67
|
+
try:
|
|
68
|
+
headers = {"Authorization": "Bearer " + self.token}
|
|
69
|
+
if archive:
|
|
70
|
+
headers.update({"Content-Type": "application/x-tar", "Content-Length": str(archive.stat().st_size)})
|
|
71
|
+
with archive.open("rb") as handle:
|
|
72
|
+
connection.request("POST", path, body=UploadStream(handle, archive.stat().st_size), headers=headers)
|
|
73
|
+
else:
|
|
74
|
+
payload = json.dumps(body).encode() if body is not None else None
|
|
75
|
+
headers["Content-Type"] = "application/json"
|
|
76
|
+
connection.request("POST" if body is not None else "GET", path, body=payload, headers=headers)
|
|
77
|
+
response = connection.getresponse()
|
|
78
|
+
result = json.loads(response.read())
|
|
79
|
+
if response.status >= 400:
|
|
80
|
+
raise ValueError(result.get("detail", "Local inference request failed"))
|
|
81
|
+
return result
|
|
82
|
+
except (OSError, http.client.HTTPException) as error:
|
|
83
|
+
raise ValueError("Devbox inference relay unavailable. Inspect mru target status before retrying; publication may have been accepted.") from error
|
|
84
|
+
finally:
|
|
85
|
+
connection.close()
|
|
86
|
+
|
|
87
|
+
def publish(self, deployment, checkpoint, digest=None):
|
|
88
|
+
path = "/v1/deployments/" + quote(deployment, safe="") + "/publish"
|
|
89
|
+
if digest and self.request("/v1/staged/" + digest)["available"]:
|
|
90
|
+
print("Reusing the verified checkpoint already on the Ethernet box…", file=sys.stderr)
|
|
91
|
+
return self.request(path, body={"reuseStaged": True})
|
|
92
|
+
if checkpoint is None:
|
|
93
|
+
raise ValueError("Checkpoint is not staged; supply --checkpoint /path/to/pretrained_model")
|
|
94
|
+
with packed_checkpoint(checkpoint) as archive:
|
|
95
|
+
return self.request(path, archive)
|
|
96
|
+
|
|
97
|
+
def stage(self, checkpoint, name=None):
|
|
98
|
+
digest = checkpoint_digest(checkpoint)
|
|
99
|
+
name = name or "mru-" + digest[:24]
|
|
100
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", name):
|
|
101
|
+
raise ValueError("Model name must contain 1–64 letters, numbers, dots, hyphens or underscores")
|
|
102
|
+
with packed_checkpoint(checkpoint) as archive:
|
|
103
|
+
return self.request("/v1/stage?digest=" + digest + "&name=" + quote(name, safe=""), archive)
|
|
104
|
+
|
|
105
|
+
def pull(self, deployment, name=None):
|
|
106
|
+
return self.request("/v1/pull/" + quote(deployment, safe=""), body={"name": name} if name else {})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mru-cli
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Deploy pinned robot checkpoints and track MRU physical runs
|
|
5
|
+
Project-URL: Repository, https://github.com/Druzrka/mru-cli
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: modal<2,>=1.0
|
|
9
|
+
|
|
10
|
+
# MRU CLI
|
|
11
|
+
|
|
12
|
+
The standalone command-line client for [Make Robots Useful](https://app.makerobotsuseful.com).
|
|
13
|
+
It talks to the MRU API using your account token. Installation and login do not
|
|
14
|
+
require a checkpoint, a robot connection, or a Modal account.
|
|
15
|
+
|
|
16
|
+
## Install from this checkout
|
|
17
|
+
|
|
18
|
+
Requires Python 3.11 or newer. Clone this repository, then run:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python3 -m venv .venv
|
|
22
|
+
.venv/bin/python -m pip install .
|
|
23
|
+
.venv/bin/mru --help
|
|
24
|
+
.venv/bin/mru --url https://app.makerobotsuseful.com login
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Create an account token at [Connect](https://app.makerobotsuseful.com/#connect)
|
|
28
|
+
and enter it in the hidden login prompt. Login verifies your identity through
|
|
29
|
+
`/v1/me` and saves credentials with mode `0600` in `~/.config/mru/config.json`.
|
|
30
|
+
Do not put tokens in chat, Git, or command arguments. `mru logout` removes the
|
|
31
|
+
local credentials; revoke tokens in the dashboard.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
mru --url https://app.makerobotsuseful.com --json deployments
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Robot access is assigned by MRU separately. A new account can log in without
|
|
38
|
+
being assigned a robot. Network restrictions in an agent's environment must
|
|
39
|
+
permit the MRU API domain even when package installation succeeds.
|
|
40
|
+
|
|
41
|
+
## Upload a trained checkpoint
|
|
42
|
+
|
|
43
|
+
ACT (`act`) and pi0.5 (`pi05`) are supported checkpoint formats, not models
|
|
44
|
+
included with an account. Use your own complete trained checkpoint, including
|
|
45
|
+
weights, config, preprocessor and postprocessor. Cloud publication also needs
|
|
46
|
+
the assigned Modal workspace/environment, an existing volume, and a validated
|
|
47
|
+
runtime image. The control rate must match training.
|
|
48
|
+
|
|
49
|
+
Use `mru init --help`, `mru check --help`, and `mru deploy --help` for project
|
|
50
|
+
settings. `mru deploy ... --no-run` publishes and validates without queuing a
|
|
51
|
+
physical trial. Running a trial requires explicit authorization; plain
|
|
52
|
+
`mru deploy` also queues a run.
|
|
53
|
+
|
|
54
|
+
`mru target` manages optional local inference through the commissioned Devbox
|
|
55
|
+
relay. It is not required for CLI installation or login. The CLI itself does
|
|
56
|
+
not contain the robot operator services.
|
|
57
|
+
|
|
58
|
+
## Develop and test
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
.venv/bin/python -m pip install -e .
|
|
62
|
+
.venv/bin/python -m unittest discover -s tests -v
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The client manifest contract in `mru_cli/manifest.py` implements
|
|
66
|
+
`mru.physical-deployment/v1`. Its server counterpart lives in
|
|
67
|
+
`mru-web/backend/deployment_manifest.py`. Contract changes require compatibility
|
|
68
|
+
tests on both sides; moving repositories does not change the wire protocol.
|
|
69
|
+
|
|
70
|
+
## Build and publish
|
|
71
|
+
|
|
72
|
+
Version 0.1.2 moves the inference wrapper into `mru_cli.modal_inference`, removing
|
|
73
|
+
the dependency on the web repository's `integrations` namespace.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
.venv/bin/python scripts/build_release.py
|
|
77
|
+
shasum -a 256 releases/mru_cli-0.1.2-py3-none-any.whl
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Review and commit the wheel together with its source. The manual
|
|
81
|
+
`publish-cli.yml` workflow stages only the wheel matching the supplied SHA256.
|
|
82
|
+
It runs only from `main`; pushing code does not publish a package.
|
|
83
|
+
|
|
84
|
+
Before the first release from this repository, configure a PyPI trusted
|
|
85
|
+
publisher for project `mru-cli`, owner `Druzrka`, repository `mru-cli`, workflow
|
|
86
|
+
`publish-cli.yml`, environment `pypi`. Configure the GitHub `pypi` environment to
|
|
87
|
+
allow only `main`. The previous publisher for `mru-web` does not authorize this
|
|
88
|
+
repository. No PyPI release is performed as part of this extraction.
|
|
89
|
+
|
|
90
|
+
This repository was extracted from `Druzrka/mru-web` commit
|
|
91
|
+
`7bd1a908f8cb7ee993201462a452a7b4369c0897`. It contains only customer CLI code,
|
|
92
|
+
its prediction-only inference wrapper, tests, and release tooling.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
mru_cli/__init__.py,sha256=YhaMTC9M8xkcHVrbI0-ixgfliHfe2tiDqXSOxf4uBMw,27
|
|
2
|
+
mru_cli/__main__.py,sha256=EwrK-u-Eg2JchXI165ldrOFBIaW6EO7Cx0ZMnwRRU6c,49
|
|
3
|
+
mru_cli/main.py,sha256=YiavBIGsiCckKs3Oopd23zjttYFF41GMUoODM5Vhjmk,21125
|
|
4
|
+
mru_cli/manifest.py,sha256=rxVqFNoope-VNFdztFg6fH2qogR9wuUc-ed2z6WjoiI,4125
|
|
5
|
+
mru_cli/modal_inference.py,sha256=6Rp013281Q0CSUaiZ0IGODOzI9oa_LhZGttGumtH4Bg,7046
|
|
6
|
+
mru_cli/project.py,sha256=4PL3W2Ivood6RdSV5U01xaOkLO8OhPhc75D69Tco3P4,4600
|
|
7
|
+
mru_cli/targets.py,sha256=CkA9ekhLxDeSAWXaP6AXzDet5i1gaIFLYVuXnZzrE20,4956
|
|
8
|
+
mru_cli-0.1.2.dist-info/METADATA,sha256=sLL_baGeCX_Fgb1fgX0hKP_DGobyD78LM-D6DUzWYd0,3777
|
|
9
|
+
mru_cli-0.1.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
mru_cli-0.1.2.dist-info/entry_points.txt,sha256=zsi5UooB8s2XG-5sGUblO9zy9xq-CNM1ED1P1f4vs8M,42
|
|
11
|
+
mru_cli-0.1.2.dist-info/top_level.txt,sha256=AScFE93QcEweyjCxy7S724GfkJSUPOnJ6nLvgbIeljg,8
|
|
12
|
+
mru_cli-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mru_cli
|