rhylthyme-cli-runner 0.3.0a0__tar.gz → 0.4.0a0__tar.gz
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.
- {rhylthyme_cli_runner-0.3.0a0/src/rhylthyme_cli_runner.egg-info → rhylthyme_cli_runner-0.4.0a0}/PKG-INFO +4 -2
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/setup.py +4 -2
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/__init__.py +1 -1
- rhylthyme_cli_runner-0.4.0a0/src/rhylthyme_cli_runner/bridge.py +427 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/bridge_serve.py +25 -16
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/cli.py +23 -19
- rhylthyme_cli_runner-0.4.0a0/src/rhylthyme_cli_runner/drivers/__init__.py +645 -0
- rhylthyme_cli_runner-0.4.0a0/src/rhylthyme_cli_runner/drivers/galago.py +156 -0
- rhylthyme_cli_runner-0.4.0a0/src/rhylthyme_cli_runner/drivers/labmcp.py +263 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/recorder.py +9 -1
- rhylthyme_cli_runner-0.4.0a0/src/rhylthyme_cli_runner/instruments.py +905 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/program_runner.py +126 -42
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0/src/rhylthyme_cli_runner.egg-info}/PKG-INFO +4 -2
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner.egg-info/SOURCES.txt +9 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner.egg-info/requires.txt +4 -1
- rhylthyme_cli_runner-0.4.0a0/tests/test_drivers.py +452 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_instruments.py +3 -0
- rhylthyme_cli_runner-0.4.0a0/tests/test_labmcp.py +647 -0
- rhylthyme_cli_runner-0.4.0a0/tests/test_labmcp_bridge.py +374 -0
- rhylthyme_cli_runner-0.4.0a0/tests/test_phase_actions.py +325 -0
- rhylthyme_cli_runner-0.4.0a0/tests/test_safe_stops.py +269 -0
- rhylthyme_cli_runner-0.3.0a0/src/rhylthyme_cli_runner/instruments.py +0 -342
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/LICENSE +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/README.md +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/pyproject.toml +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/setup.cfg +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/environment_icons.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/environment_loader.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/environment_schemas.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/__init__.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/cli.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/compare.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/gold.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/harness.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/llm.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/matcher.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/metrics.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/__init__.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/authoring_guide.md +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/baseline.md +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/baseline.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/extract.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/four_turn.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/report.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/expand_replicates.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/__init__.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/calibrate.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/calibrate_cli.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/cli.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/evaluate.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/evaluate_cli.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/factors.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/hash.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/predict.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/render.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/replay.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/report.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/report_cli.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/store.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/synth.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/usable.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/import_cli.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/instance_checks.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/program_planner.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/remote/__init__.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/remote/auth.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/remote/checks.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/remote/cli.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/remote/mcp_client.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/render_cli.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/validate_program.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner.egg-info/dependency_links.txt +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner.egg-info/entry_points.txt +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner.egg-info/top_level.txt +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_bridge_serve.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_calibrate.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_cli.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_eval_baseline.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_eval_harness.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_eval_providers.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_eval_scorer.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_evaluate.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_example_validation.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_examples_integration.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_expand_replicates.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_hash_parity.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_history_report.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_import_cli.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_mcp_checks.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_planner_time_strings.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_predict.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_program_runner.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_remote.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_render_cli.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_replay.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_run_recorder.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_skill.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_synth.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_usable.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_validate_examples_ci.py +0 -0
- {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_validate_program.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rhylthyme-cli-runner
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0a0
|
|
4
4
|
Summary: CLI runner for Rhylthyme real-time program schedules
|
|
5
5
|
Home-page: https://github.com/rhylthyme/rhylthyme-cli-runner
|
|
6
6
|
Author: Rhylthyme Team
|
|
@@ -26,9 +26,11 @@ Requires-Dist: click>=8.0.0
|
|
|
26
26
|
Requires-Dist: jsonschema>=4.0.0
|
|
27
27
|
Requires-Dist: pyyaml>=6.0
|
|
28
28
|
Requires-Dist: colorama>=0.4.0
|
|
29
|
-
Requires-Dist: rhylthyme-spec>=0.2.
|
|
29
|
+
Requires-Dist: rhylthyme-spec>=0.2.2a0
|
|
30
30
|
Provides-Extra: galago
|
|
31
31
|
Requires-Dist: rhylthyme-galago>=0.2.0a0; extra == "galago"
|
|
32
|
+
Provides-Extra: labmcp
|
|
33
|
+
Requires-Dist: rhylthyme-labmcp>=0.1.0a0; extra == "labmcp"
|
|
32
34
|
Provides-Extra: dev
|
|
33
35
|
Requires-Dist: pytest>=6.0.0; extra == "dev"
|
|
34
36
|
Requires-Dist: pytest-cov>=2.10.0; extra == "dev"
|
|
@@ -19,7 +19,7 @@ def read_readme():
|
|
|
19
19
|
|
|
20
20
|
setup(
|
|
21
21
|
name="rhylthyme-cli-runner",
|
|
22
|
-
version="0.
|
|
22
|
+
version="0.4.0a0",
|
|
23
23
|
description="CLI runner for Rhylthyme real-time program schedules",
|
|
24
24
|
long_description=read_readme(),
|
|
25
25
|
long_description_content_type="text/markdown",
|
|
@@ -38,11 +38,13 @@ setup(
|
|
|
38
38
|
"jsonschema>=4.0.0",
|
|
39
39
|
"pyyaml>=6.0",
|
|
40
40
|
"colorama>=0.4.0",
|
|
41
|
-
"rhylthyme-spec>=0.2.
|
|
41
|
+
"rhylthyme-spec>=0.2.2a0",
|
|
42
42
|
],
|
|
43
43
|
extras_require={
|
|
44
44
|
# Instrument steps on galago-tools lab instruments (--workcell, rhylthyme bridge)
|
|
45
45
|
"galago": ["rhylthyme-galago>=0.2.0a0"],
|
|
46
|
+
# Instrument steps on LabMCP instrument servers (driver "labmcp" in a workcell)
|
|
47
|
+
"labmcp": ["rhylthyme-labmcp>=0.1.0a0"],
|
|
46
48
|
"dev": [
|
|
47
49
|
# Testing framework
|
|
48
50
|
"pytest>=6.0.0",
|
{rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/__init__.py
RENAMED
|
@@ -6,7 +6,7 @@ This package provides the command-line interface for running and validating
|
|
|
6
6
|
Rhylthyme real-time program schedules.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
__version__ = "0.
|
|
9
|
+
__version__ = "0.4.0a0"
|
|
10
10
|
__author__ = "Rhylthyme Team"
|
|
11
11
|
__description__ = "CLI runner for Rhylthyme real-time program schedules"
|
|
12
12
|
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""Publish a live instrument run to rhylthyme.com and take commands from it.
|
|
2
|
+
|
|
3
|
+
``rhylthyme bridge --workcell lab.json PROGRAM`` runs a program exactly like
|
|
4
|
+
``rhylthyme run`` and, while it runs, keeps two rows up to date in the user's
|
|
5
|
+
own Supabase tables (owner-only RLS; see rhylthyme-server/sql/galago_bridges.sql
|
|
6
|
+
and docs/design/web-bridge.md):
|
|
7
|
+
|
|
8
|
+
- ``bridges``: this lab machine, its tools (name, type, status) and a
|
|
9
|
+
heartbeat;
|
|
10
|
+
- ``bridge_state``: the run's steps, their status, the tool each waits on and
|
|
11
|
+
any failure.
|
|
12
|
+
|
|
13
|
+
With a ``submit`` callback it also reads the user's ``bridge_commands`` rows
|
|
14
|
+
(pause, resume, retry, skip, abort), refuses stale, foreign or unknown ones,
|
|
15
|
+
lets the runner decide the rest, and writes each outcome back to its row.
|
|
16
|
+
|
|
17
|
+
Only outbound HTTPS, with the user's session. Nothing about where a tool
|
|
18
|
+
listens ever leaves the machine: rows are built from an allowlist of fields,
|
|
19
|
+
and error text is scrubbed of every private value each tool's driver names
|
|
20
|
+
(galago hosts and ports; LabMCP serial, TCP and VISA addresses, server URLs
|
|
21
|
+
and options).
|
|
22
|
+
|
|
23
|
+
Moved here from rhylthyme_galago.bridge so that every instrument driver can
|
|
24
|
+
use it; that module stays for compatibility.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import base64
|
|
28
|
+
import json
|
|
29
|
+
import re
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
34
|
+
import uuid
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional
|
|
37
|
+
|
|
38
|
+
from .drivers import blocking_action
|
|
39
|
+
|
|
40
|
+
TokenFn = Callable[[], str]
|
|
41
|
+
|
|
42
|
+
HEARTBEAT_SECONDS = 10.0
|
|
43
|
+
STATE_SECONDS = 1.0
|
|
44
|
+
|
|
45
|
+
#: Commands the browser may send (design note: "The command set"): steering a
|
|
46
|
+
#: run in progress, and starting one on an idle bridge.
|
|
47
|
+
STEER_KINDS = ("pause", "resume", "retry", "skip", "abort")
|
|
48
|
+
START_KINDS = ("start_run",)
|
|
49
|
+
|
|
50
|
+
#: A command older than this when the bridge sees it is refused, so a
|
|
51
|
+
#: reconnecting bridge never replays a stale retry or abort.
|
|
52
|
+
COMMAND_MAX_AGE_SECONDS = 30.0
|
|
53
|
+
|
|
54
|
+
#: Called with {"id", "kind", "args"}; returns {"accepted", "reason"} once the
|
|
55
|
+
#: runner has decided, or None if it did not answer in time.
|
|
56
|
+
Submit = Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class BridgeError(RuntimeError):
|
|
60
|
+
"""The bridge could not reach or write to rhylthyme.com."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def user_id_from_token(token: str) -> str:
|
|
64
|
+
"""The ``sub`` of a Supabase JWT (the server re-checks it through RLS)."""
|
|
65
|
+
try:
|
|
66
|
+
payload = token.split(".")[1]
|
|
67
|
+
payload += "=" * (-len(payload) % 4)
|
|
68
|
+
return json.loads(base64.urlsafe_b64decode(payload))["sub"]
|
|
69
|
+
except Exception as e: # noqa: BLE001
|
|
70
|
+
raise BridgeError(
|
|
71
|
+
"The saved session is not a usable token; run `rhylthyme login`."
|
|
72
|
+
) from e
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def bridge_id_for(workcell: Any, config_path: Path) -> str:
|
|
76
|
+
"""A stable id per workcell on this machine, kept in ``config_path``."""
|
|
77
|
+
try:
|
|
78
|
+
ids = json.loads(config_path.read_text())
|
|
79
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
80
|
+
ids = {}
|
|
81
|
+
if workcell.id not in ids:
|
|
82
|
+
ids[workcell.id] = str(uuid.uuid4())
|
|
83
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
config_path.write_text(json.dumps(ids, indent=2))
|
|
85
|
+
return ids[workcell.id]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def scrubber(workcell: Any) -> Callable[[Optional[str]], str]:
|
|
89
|
+
"""
|
|
90
|
+
Replace every private value of every workcell tool in free text: each
|
|
91
|
+
tool's ``secrets()`` (see drivers.ToolView), longest first so
|
|
92
|
+
"10.0.0.12:50010" goes before "10.0.0.12".
|
|
93
|
+
"""
|
|
94
|
+
pairs = [pair for tool in workcell.tools.values() for pair in tool.secrets()]
|
|
95
|
+
pairs.sort(key=lambda p: len(p[0]), reverse=True)
|
|
96
|
+
pattern = re.compile("|".join(re.escape(p[0]) for p in pairs)) if pairs else None
|
|
97
|
+
mapping = dict(pairs)
|
|
98
|
+
|
|
99
|
+
def scrub(text: Optional[str]) -> str:
|
|
100
|
+
if not text or pattern is None:
|
|
101
|
+
return text or ""
|
|
102
|
+
return pattern.sub(lambda m: mapping[m.group(0)], text)
|
|
103
|
+
|
|
104
|
+
return scrub
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _status_value(status: Any) -> str:
|
|
108
|
+
return getattr(status, "value", str(status))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def run_status(runner: Any) -> str:
|
|
112
|
+
if getattr(runner, "program_abort_reason", None):
|
|
113
|
+
return "aborted"
|
|
114
|
+
if getattr(runner, "failed_steps", None):
|
|
115
|
+
return "failed"
|
|
116
|
+
steps = list(runner.steps.values())
|
|
117
|
+
if steps and all(_status_value(s.status) == "COMPLETED" for s in steps):
|
|
118
|
+
return "completed"
|
|
119
|
+
if getattr(runner, "is_paused", False):
|
|
120
|
+
return "paused"
|
|
121
|
+
if getattr(runner, "is_running", False):
|
|
122
|
+
return "running"
|
|
123
|
+
return "idle"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def snapshot(runner: Any, scrub: Callable[[Optional[str]], str]) -> Dict[str, Any]:
|
|
127
|
+
"""The run as the web sees it: an allowlist of fields, error text scrubbed."""
|
|
128
|
+
t0 = getattr(runner, "program_start_time", None)
|
|
129
|
+
|
|
130
|
+
def rel(t: Optional[float]) -> Optional[float]:
|
|
131
|
+
return round(t - t0, 2) if (t is not None and t0 is not None) else None
|
|
132
|
+
|
|
133
|
+
steps = []
|
|
134
|
+
for step in runner.steps.values():
|
|
135
|
+
instrument = step.instrument or {}
|
|
136
|
+
main = blocking_action(instrument) or {}
|
|
137
|
+
status = _status_value(step.status)
|
|
138
|
+
failure = None
|
|
139
|
+
if step.failure:
|
|
140
|
+
failure = {
|
|
141
|
+
"code": str(step.failure.get("code") or ""),
|
|
142
|
+
"errorMessage": scrub(step.failure.get("errorMessage")),
|
|
143
|
+
}
|
|
144
|
+
steps.append(
|
|
145
|
+
{
|
|
146
|
+
"stepId": step.step_id,
|
|
147
|
+
"name": step.name,
|
|
148
|
+
"trackId": step.track_id,
|
|
149
|
+
"status": status,
|
|
150
|
+
"start": rel(step.start_time),
|
|
151
|
+
"end": rel(step.end_time),
|
|
152
|
+
"tool": instrument.get("tool"),
|
|
153
|
+
# The call the step waits on: its command or its until action
|
|
154
|
+
"command": main.get("command"),
|
|
155
|
+
"waitingOn": (
|
|
156
|
+
main.get("tool") if (main and status == "RUNNING") else None
|
|
157
|
+
),
|
|
158
|
+
"failure": failure,
|
|
159
|
+
"abortReason": scrub(step.abort_reason) if step.abort_reason else None,
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
clock = None
|
|
163
|
+
if t0 is not None:
|
|
164
|
+
clock = round(runner.current_time - t0, 2)
|
|
165
|
+
return {
|
|
166
|
+
"clock": clock,
|
|
167
|
+
"paused": bool(getattr(runner, "is_paused", False)),
|
|
168
|
+
"abortReason": scrub(getattr(runner, "program_abort_reason", None)) or None,
|
|
169
|
+
"steps": steps,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class SupabaseRest:
|
|
174
|
+
"""Minimal PostgREST client acting as the signed-in user."""
|
|
175
|
+
|
|
176
|
+
def __init__(self, url: str, anon_key: str, token: TokenFn, timeout: float = 10.0):
|
|
177
|
+
self.url = url.rstrip("/") + "/rest/v1"
|
|
178
|
+
self.anon_key = anon_key
|
|
179
|
+
self.token = token
|
|
180
|
+
self.timeout = timeout
|
|
181
|
+
|
|
182
|
+
def select(self, table: str, query: str) -> List[Dict[str, Any]]:
|
|
183
|
+
return self._request("GET", f"/{table}?{query}", None, {}) or []
|
|
184
|
+
|
|
185
|
+
def upsert(self, table: str, row: Mapping[str, Any], on_conflict: str) -> None:
|
|
186
|
+
self._request(
|
|
187
|
+
"POST",
|
|
188
|
+
f"/{table}?on_conflict={on_conflict}",
|
|
189
|
+
row,
|
|
190
|
+
{"Prefer": "resolution=merge-duplicates,return=minimal"},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def patch(self, table: str, match: str, row: Mapping[str, Any]) -> None:
|
|
194
|
+
self._request("PATCH", f"/{table}?{match}", row, {"Prefer": "return=minimal"})
|
|
195
|
+
|
|
196
|
+
def _request(self, method: str, path: str, body: Any, extra: Dict[str, str]) -> Any:
|
|
197
|
+
headers = {
|
|
198
|
+
"apikey": self.anon_key,
|
|
199
|
+
"Authorization": f"Bearer {self.token()}",
|
|
200
|
+
"Content-Type": "application/json",
|
|
201
|
+
**extra,
|
|
202
|
+
}
|
|
203
|
+
data = None if body is None else json.dumps(body).encode()
|
|
204
|
+
req = urllib.request.Request(
|
|
205
|
+
self.url + path, data=data, headers=headers, method=method
|
|
206
|
+
)
|
|
207
|
+
try:
|
|
208
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
209
|
+
raw = resp.read()
|
|
210
|
+
return json.loads(raw) if raw else None
|
|
211
|
+
except urllib.error.HTTPError as e:
|
|
212
|
+
detail = e.read().decode("utf-8", "replace")[:300]
|
|
213
|
+
raise BridgeError(
|
|
214
|
+
f"{method} {path.split('?')[0]}: HTTP {e.code} {detail}"
|
|
215
|
+
) from e
|
|
216
|
+
except urllib.error.URLError as e:
|
|
217
|
+
raise BridgeError(f"Could not reach rhylthyme.com: {e.reason}") from e
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class Publisher:
|
|
221
|
+
"""Keeps ``bridges`` and ``bridge_state`` current on a background thread.
|
|
222
|
+
|
|
223
|
+
With a ``runner`` it publishes that run and passes steering commands to
|
|
224
|
+
``submit``. With ``runner=None`` the bridge is idle: it keeps its heartbeat,
|
|
225
|
+
leaves the last run's state as it was, and passes ``start_run`` on.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
def __init__(
|
|
229
|
+
self,
|
|
230
|
+
runner: Any,
|
|
231
|
+
*,
|
|
232
|
+
rest: SupabaseRest,
|
|
233
|
+
bridge_id: str,
|
|
234
|
+
user_id: str,
|
|
235
|
+
workcell: Any,
|
|
236
|
+
tools: List[Dict[str, str]],
|
|
237
|
+
program: Optional[Mapping[str, Any]] = None,
|
|
238
|
+
mode: str = "simulated",
|
|
239
|
+
allows_live: bool,
|
|
240
|
+
version: str,
|
|
241
|
+
on_error: Optional[Callable[[str], None]] = None,
|
|
242
|
+
submit: Optional[Submit] = None,
|
|
243
|
+
clock: Callable[[], float] = time.time,
|
|
244
|
+
program_id: Optional[str] = None,
|
|
245
|
+
):
|
|
246
|
+
self.runner = runner
|
|
247
|
+
self.rest = rest
|
|
248
|
+
self.bridge_id = bridge_id
|
|
249
|
+
self.user_id = user_id
|
|
250
|
+
self.workcell = workcell
|
|
251
|
+
self.tools = tools
|
|
252
|
+
self.program = program or {}
|
|
253
|
+
self.program_id = program_id
|
|
254
|
+
self.mode = mode
|
|
255
|
+
self.accepts = STEER_KINDS if runner is not None else START_KINDS
|
|
256
|
+
self.allows_live = allows_live
|
|
257
|
+
self.version = version
|
|
258
|
+
self.scrub = scrubber(workcell)
|
|
259
|
+
self.run_id = str(uuid.uuid4())
|
|
260
|
+
self.on_error = on_error or (lambda message: None)
|
|
261
|
+
self.submit = submit # None: watch only, commands are not read
|
|
262
|
+
self.clock = clock
|
|
263
|
+
self._handled: set = set()
|
|
264
|
+
self.last_error: Optional[str] = None
|
|
265
|
+
self._stop = threading.Event()
|
|
266
|
+
self._thread: Optional[threading.Thread] = None
|
|
267
|
+
self._last_state: Optional[str] = None
|
|
268
|
+
self._last_heartbeat = 0.0
|
|
269
|
+
|
|
270
|
+
def bridge_row(self) -> Dict[str, Any]:
|
|
271
|
+
return {
|
|
272
|
+
"id": self.bridge_id,
|
|
273
|
+
"user_id": self.user_id,
|
|
274
|
+
"name": self.workcell.name,
|
|
275
|
+
"allows_live": self.allows_live,
|
|
276
|
+
"tools": self.tools,
|
|
277
|
+
"version": self.version,
|
|
278
|
+
"last_seen": _now_iso(),
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
def state_row(self) -> Optional[Dict[str, Any]]:
|
|
282
|
+
if self.runner is None:
|
|
283
|
+
return None # idle: the last run's state stays as it was
|
|
284
|
+
state = snapshot(self.runner, self.scrub)
|
|
285
|
+
state["program"] = self.program
|
|
286
|
+
return {
|
|
287
|
+
"bridge_id": self.bridge_id,
|
|
288
|
+
"user_id": self.user_id,
|
|
289
|
+
"run_id": self.run_id,
|
|
290
|
+
"program_id": self.program_id,
|
|
291
|
+
"program_name": self.program.get("name"),
|
|
292
|
+
"mode": self.mode,
|
|
293
|
+
"status": run_status(self.runner),
|
|
294
|
+
"state": state,
|
|
295
|
+
"updated_at": _now_iso(),
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
def publish_once(self, force: bool = False) -> None:
|
|
299
|
+
now = time.time()
|
|
300
|
+
if force or now - self._last_heartbeat >= HEARTBEAT_SECONDS:
|
|
301
|
+
self.rest.upsert("bridges", self.bridge_row(), "id")
|
|
302
|
+
self._last_heartbeat = now
|
|
303
|
+
row = self.state_row()
|
|
304
|
+
if row is None:
|
|
305
|
+
return
|
|
306
|
+
key = json.dumps(
|
|
307
|
+
{k: v for k, v in row.items() if k != "updated_at"}, sort_keys=True
|
|
308
|
+
)
|
|
309
|
+
if force or key != self._last_state:
|
|
310
|
+
self.rest.upsert("bridge_state", row, "bridge_id")
|
|
311
|
+
self._last_state = key
|
|
312
|
+
|
|
313
|
+
def poll_commands(self) -> None:
|
|
314
|
+
"""Handle pending browser commands, oldest first, once each."""
|
|
315
|
+
if self.submit is None:
|
|
316
|
+
return
|
|
317
|
+
pending = self.rest.select(
|
|
318
|
+
"bridge_commands",
|
|
319
|
+
f"bridge_id=eq.{self.bridge_id}&status=eq.pending&order=created_at.asc&select=*",
|
|
320
|
+
)
|
|
321
|
+
for command in pending:
|
|
322
|
+
command_id = str(command.get("id"))
|
|
323
|
+
if command_id in self._handled:
|
|
324
|
+
continue
|
|
325
|
+
self._handled.add(command_id)
|
|
326
|
+
outcome = self._decide(command)
|
|
327
|
+
self.rest.patch(
|
|
328
|
+
"bridge_commands",
|
|
329
|
+
f"id=eq.{command_id}&status=eq.pending",
|
|
330
|
+
{
|
|
331
|
+
"status": "done" if outcome["accepted"] else "rejected",
|
|
332
|
+
"result": outcome,
|
|
333
|
+
"updated_at": _now_iso(),
|
|
334
|
+
},
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def _decide(self, command: Mapping[str, Any]) -> Dict[str, Any]:
|
|
338
|
+
def no(reason: str) -> Dict[str, Any]:
|
|
339
|
+
return {"accepted": False, "reason": reason}
|
|
340
|
+
|
|
341
|
+
if command.get("user_id") != self.user_id:
|
|
342
|
+
return no("not your bridge")
|
|
343
|
+
age = self.clock() - _parse_iso(command.get("created_at"))
|
|
344
|
+
if age > COMMAND_MAX_AGE_SECONDS:
|
|
345
|
+
return no(f"expired ({int(age)} s old)")
|
|
346
|
+
kind = command.get("kind")
|
|
347
|
+
if kind not in STEER_KINDS + START_KINDS:
|
|
348
|
+
return no(f"unknown command {kind!r}")
|
|
349
|
+
if kind not in self.accepts:
|
|
350
|
+
if kind in START_KINDS:
|
|
351
|
+
return no("a run is already in progress")
|
|
352
|
+
return no("no run in progress")
|
|
353
|
+
args = command.get("args") if isinstance(command.get("args"), dict) else {}
|
|
354
|
+
if self.submit is None:
|
|
355
|
+
return no("this bridge does not take commands")
|
|
356
|
+
outcome = self.submit(
|
|
357
|
+
{"id": str(command.get("id")), "kind": kind, "args": args}
|
|
358
|
+
)
|
|
359
|
+
if outcome is None:
|
|
360
|
+
return no("the runner did not answer")
|
|
361
|
+
return {
|
|
362
|
+
"accepted": bool(outcome.get("accepted")),
|
|
363
|
+
"reason": outcome.get("reason") or "",
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
def start(self) -> "Publisher":
|
|
367
|
+
self.publish_once(force=True) # fail fast: bad session, missing tables
|
|
368
|
+
self._thread = threading.Thread(
|
|
369
|
+
target=self._loop, name="rhylthyme-bridge", daemon=True
|
|
370
|
+
)
|
|
371
|
+
self._thread.start()
|
|
372
|
+
return self
|
|
373
|
+
|
|
374
|
+
def _loop(self) -> None:
|
|
375
|
+
while not self._stop.wait(STATE_SECONDS):
|
|
376
|
+
try:
|
|
377
|
+
self.poll_commands()
|
|
378
|
+
self.publish_once()
|
|
379
|
+
self.last_error = None
|
|
380
|
+
except Exception as e: # noqa: BLE001 - keep running; report once
|
|
381
|
+
message = str(e)
|
|
382
|
+
if message != self.last_error:
|
|
383
|
+
self.on_error(message)
|
|
384
|
+
self.last_error = message
|
|
385
|
+
|
|
386
|
+
def stop(self) -> None:
|
|
387
|
+
"""Publish the final state, then stop."""
|
|
388
|
+
self._stop.set()
|
|
389
|
+
if self._thread is not None:
|
|
390
|
+
self._thread.join(timeout=5)
|
|
391
|
+
try:
|
|
392
|
+
self.publish_once(force=True)
|
|
393
|
+
except Exception as e: # noqa: BLE001
|
|
394
|
+
self.on_error(str(e))
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _now_iso() -> str:
|
|
398
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _parse_iso(value: Any) -> float:
|
|
402
|
+
"""Seconds since the epoch for a Postgres/ISO timestamp (UTC if no zone)."""
|
|
403
|
+
from datetime import datetime, timezone
|
|
404
|
+
|
|
405
|
+
text = str(value or "").replace("Z", "+00:00")
|
|
406
|
+
try:
|
|
407
|
+
parsed = datetime.fromisoformat(text)
|
|
408
|
+
except ValueError:
|
|
409
|
+
return 0.0 # unparseable: treat as ancient, so it expires
|
|
410
|
+
if parsed.tzinfo is None:
|
|
411
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
412
|
+
return parsed.timestamp()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
__all__ = [
|
|
416
|
+
"BridgeError",
|
|
417
|
+
"COMMAND_MAX_AGE_SECONDS",
|
|
418
|
+
"START_KINDS",
|
|
419
|
+
"STEER_KINDS",
|
|
420
|
+
"Publisher",
|
|
421
|
+
"SupabaseRest",
|
|
422
|
+
"bridge_id_for",
|
|
423
|
+
"run_status",
|
|
424
|
+
"scrubber",
|
|
425
|
+
"snapshot",
|
|
426
|
+
"user_id_from_token",
|
|
427
|
+
]
|
|
@@ -23,7 +23,7 @@ import re
|
|
|
23
23
|
import sys
|
|
24
24
|
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
|
|
25
25
|
|
|
26
|
-
from .instruments import
|
|
26
|
+
from .instruments import InstrumentSetupError
|
|
27
27
|
|
|
28
28
|
UUID_RE = re.compile(
|
|
29
29
|
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I
|
|
@@ -105,15 +105,11 @@ def serve(
|
|
|
105
105
|
out=None,
|
|
106
106
|
allow_live: bool = False,
|
|
107
107
|
client_factory: Optional[Callable] = None,
|
|
108
|
+
driver_options: Optional[Mapping[str, Mapping[str, Any]]] = None,
|
|
108
109
|
) -> int:
|
|
109
110
|
"""Wait for runs from the web until Ctrl-C (or ``max_runs``). Returns runs started."""
|
|
110
111
|
out = out or sys.stdout
|
|
111
|
-
|
|
112
|
-
import rhylthyme_galago as galago
|
|
113
|
-
from rhylthyme_galago import bridge as B
|
|
114
|
-
except ImportError:
|
|
115
|
-
raise InstrumentSetupError(INSTALL_HINT) from None
|
|
116
|
-
|
|
112
|
+
from . import bridge as B
|
|
117
113
|
from .remote import auth
|
|
118
114
|
from .validate_program import load_program_file
|
|
119
115
|
|
|
@@ -123,10 +119,19 @@ def serve(
|
|
|
123
119
|
run = run_program
|
|
124
120
|
run_fn: Callable[..., Any] = run
|
|
125
121
|
|
|
122
|
+
from .drivers import DriverUnavailable, WorkcellError, load_workcell
|
|
123
|
+
from .instruments import bridge_workcell, open_drivers
|
|
124
|
+
|
|
126
125
|
try:
|
|
127
|
-
workcell =
|
|
128
|
-
except
|
|
126
|
+
workcell = load_workcell(workcell_source)
|
|
127
|
+
except (WorkcellError, DriverUnavailable) as e:
|
|
129
128
|
raise InstrumentSetupError(str(e)) from None
|
|
129
|
+
# Opened only to describe the tools and what to scrub; touches nothing
|
|
130
|
+
described = open_drivers(workcell, workcell.by_driver())
|
|
131
|
+
for driver in described.values():
|
|
132
|
+
driver.shutdown()
|
|
133
|
+
view = bridge_workcell(workcell, described)
|
|
134
|
+
version = ", ".join(v for v in (d.version for d in described.values()) if v)
|
|
130
135
|
try:
|
|
131
136
|
if rest is None or token_fn is None:
|
|
132
137
|
creds = auth.load_credentials() or {}
|
|
@@ -143,15 +148,19 @@ def serve(
|
|
|
143
148
|
raise InstrumentSetupError(str(e)) from None
|
|
144
149
|
|
|
145
150
|
bridge_id = B.bridge_id_for(
|
|
146
|
-
|
|
151
|
+
view, config_path or (auth.config_dir() / "bridges.json")
|
|
147
152
|
)
|
|
148
153
|
schema = load_program_file(schema_file)
|
|
149
154
|
tools: List[Dict[str, str]] = [
|
|
150
|
-
{
|
|
151
|
-
|
|
155
|
+
{
|
|
156
|
+
"name": e.name,
|
|
157
|
+
"type": described[e.driver].tool_type(e.name),
|
|
158
|
+
"status": "idle",
|
|
159
|
+
}
|
|
160
|
+
for e in sorted(workcell.tools.values(), key=lambda e: e.index)
|
|
152
161
|
]
|
|
153
162
|
starts: "queue.Queue[Tuple[Dict[str, Any], str, Any, bool]]" = queue.Queue()
|
|
154
|
-
scrub = B.scrubber(
|
|
163
|
+
scrub = B.scrubber(view)
|
|
155
164
|
|
|
156
165
|
def fetch_program(program_id: str) -> Optional[Mapping[str, Any]]:
|
|
157
166
|
rows = rest.select(
|
|
@@ -180,7 +189,7 @@ def serve(
|
|
|
180
189
|
|
|
181
190
|
session = None
|
|
182
191
|
try:
|
|
183
|
-
kwargs: Dict[str, Any] = {"live": live}
|
|
192
|
+
kwargs: Dict[str, Any] = {"live": live, "driver_options": driver_options}
|
|
184
193
|
if client_factory is not None:
|
|
185
194
|
kwargs["client_factory"] = client_factory
|
|
186
195
|
session = open_instruments(program, workcell_source, **kwargs)
|
|
@@ -212,10 +221,10 @@ def serve(
|
|
|
212
221
|
rest=rest,
|
|
213
222
|
bridge_id=bridge_id,
|
|
214
223
|
user_id=user_id,
|
|
215
|
-
workcell=
|
|
224
|
+
workcell=view,
|
|
216
225
|
tools=tools,
|
|
217
226
|
allows_live=allow_live,
|
|
218
|
-
version=
|
|
227
|
+
version=version,
|
|
219
228
|
on_error=lambda message: print(f"Bridge: {message}", file=out),
|
|
220
229
|
submit=submit,
|
|
221
230
|
)
|
{rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/cli.py
RENAMED
|
@@ -110,8 +110,8 @@ def cli(ctx, environments_dir):
|
|
|
110
110
|
type=click.Path(exists=True, dir_okay=False),
|
|
111
111
|
default=None,
|
|
112
112
|
help=(
|
|
113
|
-
"Workcell file: check instrument steps against its galago
|
|
114
|
-
"
|
|
113
|
+
"Workcell file: check instrument steps against its tools (galago or "
|
|
114
|
+
"LabMCP: tool names, types, commands, params and limits)"
|
|
115
115
|
),
|
|
116
116
|
)
|
|
117
117
|
def validate(
|
|
@@ -126,8 +126,9 @@ def validate(
|
|
|
126
126
|
Use --json to get machine-readable output for CI or scripting.
|
|
127
127
|
Use --strict to require all tasks used in steps/buffers to be defined in resourceConstraints.
|
|
128
128
|
Use -e/--environment to validate against specific environment constraints.
|
|
129
|
-
Use --workcell to check instrument steps against a lab's galago
|
|
130
|
-
without it, commands are checked against each step's
|
|
129
|
+
Use --workcell to check instrument steps against a lab's tools (galago
|
|
130
|
+
or LabMCP); without it, commands are checked against each step's
|
|
131
|
+
toolType (a galago tool type, or a LabMCP server such as labmcp-ika).
|
|
131
132
|
"""
|
|
132
133
|
# Set up environment for validation if specified
|
|
133
134
|
if environment:
|
|
@@ -275,15 +276,15 @@ def validate(
|
|
|
275
276
|
default=None,
|
|
276
277
|
help=(
|
|
277
278
|
"Workcell file mapping the program's instrument tools to galago-tools "
|
|
278
|
-
"servers; required for programs with instrument steps"
|
|
279
|
+
"or LabMCP servers; required for programs with instrument steps"
|
|
279
280
|
),
|
|
280
281
|
)
|
|
281
282
|
@click.option(
|
|
282
283
|
"--live",
|
|
283
284
|
is_flag=True,
|
|
284
285
|
help=(
|
|
285
|
-
"Run instrument steps on real hardware instead of
|
|
286
|
-
"
|
|
286
|
+
"Run instrument steps on real hardware instead of simulated mode; "
|
|
287
|
+
"shows what will run and asks first"
|
|
287
288
|
),
|
|
288
289
|
)
|
|
289
290
|
@click.option(
|
|
@@ -335,10 +336,12 @@ def run(
|
|
|
335
336
|
and --predict-context KEY=VALUE to predict for a context other than the
|
|
336
337
|
factor answers.
|
|
337
338
|
|
|
338
|
-
Programs with instrument steps need --workcell FILE
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
339
|
+
Programs with instrument steps need --workcell FILE, which maps each tool
|
|
340
|
+
to a galago-tools server (rhylthyme[galago]) or a LabMCP server
|
|
341
|
+
(rhylthyme[labmcp]). Tools run simulated unless you pass --live, which
|
|
342
|
+
shows each instrument, its limits and every hazardous action and asks you
|
|
343
|
+
to type 'live' before anything is configured; --confirm-live answers for
|
|
344
|
+
scripts.
|
|
342
345
|
"""
|
|
343
346
|
run_program(
|
|
344
347
|
program_file,
|
|
@@ -366,7 +369,7 @@ def run(
|
|
|
366
369
|
"--workcell",
|
|
367
370
|
type=click.Path(exists=True, dir_okay=False),
|
|
368
371
|
required=True,
|
|
369
|
-
help="Workcell file mapping the program's instrument tools to galago-tools servers",
|
|
372
|
+
help="Workcell file mapping the program's instrument tools to galago-tools or LabMCP servers",
|
|
370
373
|
)
|
|
371
374
|
@click.option(
|
|
372
375
|
"--live",
|
|
@@ -396,7 +399,7 @@ def bridge(
|
|
|
396
399
|
program_file, workcell, live, confirm_live, allow_live, time_scale, no_record
|
|
397
400
|
):
|
|
398
401
|
"""
|
|
399
|
-
Run a program on a
|
|
402
|
+
Run a program on a workcell and show it live on rhylthyme.com.
|
|
400
403
|
|
|
401
404
|
Without PROGRAM_FILE, wait for runs started from the Bridges page: a
|
|
402
405
|
program saved in your library, checked here against the workcell (no code
|
|
@@ -406,9 +409,9 @@ def bridge(
|
|
|
406
409
|
|
|
407
410
|
Works like `rhylthyme run --workcell`, and while the run lasts keeps your
|
|
408
411
|
Bridges page up to date: every step, the tool it waits on, any failure.
|
|
409
|
-
Needs `rhylthyme login` and
|
|
410
|
-
|
|
411
|
-
never leave this machine. From the Bridges page you can pause, resume,
|
|
412
|
+
Needs `rhylthyme login` and the workcell's drivers (pip install
|
|
413
|
+
"rhylthyme[galago]" and/or "rhylthyme[labmcp]"). Only outbound HTTPS;
|
|
414
|
+
tool addresses never leave this machine. From the Bridges page you can pause, resume,
|
|
412
415
|
retry or skip a failed step and abort; this machine checks and logs each
|
|
413
416
|
command, and Ctrl-C here always wins.
|
|
414
417
|
"""
|
|
@@ -471,7 +474,7 @@ def bridge(
|
|
|
471
474
|
default=None,
|
|
472
475
|
help=(
|
|
473
476
|
"Workcell file: estimate instrument steps that have no duration from "
|
|
474
|
-
"their tools' EstimateDuration"
|
|
477
|
+
"their tools (galago's EstimateDuration, LabMCP's params and defaults)"
|
|
475
478
|
),
|
|
476
479
|
)
|
|
477
480
|
def plan(input_file, output_file, environment, verbose, workcell):
|
|
@@ -485,8 +488,9 @@ def plan(input_file, output_file, environment, verbose, workcell):
|
|
|
485
488
|
The optimized program is saved to the specified output file.
|
|
486
489
|
|
|
487
490
|
Instrument steps without a duration get an estimated one (flagged in
|
|
488
|
-
metadata.durationEstimate)
|
|
489
|
-
duration-like
|
|
491
|
+
metadata.durationEstimate) by their tool's driver: galago asks the tool
|
|
492
|
+
with --workcell, else reads a duration-like param; LabMCP reads series
|
|
493
|
+
lengths, run lengths and wait timeouts from the params; else a default.
|
|
490
494
|
"""
|
|
491
495
|
success = plan_program(
|
|
492
496
|
input_file,
|