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.
Files changed (101) hide show
  1. {rhylthyme_cli_runner-0.3.0a0/src/rhylthyme_cli_runner.egg-info → rhylthyme_cli_runner-0.4.0a0}/PKG-INFO +4 -2
  2. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/setup.py +4 -2
  3. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/__init__.py +1 -1
  4. rhylthyme_cli_runner-0.4.0a0/src/rhylthyme_cli_runner/bridge.py +427 -0
  5. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/bridge_serve.py +25 -16
  6. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/cli.py +23 -19
  7. rhylthyme_cli_runner-0.4.0a0/src/rhylthyme_cli_runner/drivers/__init__.py +645 -0
  8. rhylthyme_cli_runner-0.4.0a0/src/rhylthyme_cli_runner/drivers/galago.py +156 -0
  9. rhylthyme_cli_runner-0.4.0a0/src/rhylthyme_cli_runner/drivers/labmcp.py +263 -0
  10. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/recorder.py +9 -1
  11. rhylthyme_cli_runner-0.4.0a0/src/rhylthyme_cli_runner/instruments.py +905 -0
  12. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/program_runner.py +126 -42
  13. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0/src/rhylthyme_cli_runner.egg-info}/PKG-INFO +4 -2
  14. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner.egg-info/SOURCES.txt +9 -0
  15. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner.egg-info/requires.txt +4 -1
  16. rhylthyme_cli_runner-0.4.0a0/tests/test_drivers.py +452 -0
  17. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_instruments.py +3 -0
  18. rhylthyme_cli_runner-0.4.0a0/tests/test_labmcp.py +647 -0
  19. rhylthyme_cli_runner-0.4.0a0/tests/test_labmcp_bridge.py +374 -0
  20. rhylthyme_cli_runner-0.4.0a0/tests/test_phase_actions.py +325 -0
  21. rhylthyme_cli_runner-0.4.0a0/tests/test_safe_stops.py +269 -0
  22. rhylthyme_cli_runner-0.3.0a0/src/rhylthyme_cli_runner/instruments.py +0 -342
  23. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/LICENSE +0 -0
  24. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/README.md +0 -0
  25. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/pyproject.toml +0 -0
  26. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/setup.cfg +0 -0
  27. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/environment_icons.py +0 -0
  28. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/environment_loader.py +0 -0
  29. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/environment_schemas.py +0 -0
  30. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/__init__.py +0 -0
  31. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/cli.py +0 -0
  32. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/compare.py +0 -0
  33. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/gold.py +0 -0
  34. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/harness.py +0 -0
  35. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/llm.py +0 -0
  36. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/matcher.py +0 -0
  37. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/metrics.py +0 -0
  38. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/__init__.py +0 -0
  39. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/authoring_guide.md +0 -0
  40. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/baseline.md +0 -0
  41. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/baseline.py +0 -0
  42. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/extract.py +0 -0
  43. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/four_turn.py +0 -0
  44. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/report.py +0 -0
  45. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/expand_replicates.py +0 -0
  46. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/__init__.py +0 -0
  47. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/calibrate.py +0 -0
  48. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/calibrate_cli.py +0 -0
  49. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/cli.py +0 -0
  50. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/evaluate.py +0 -0
  51. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/evaluate_cli.py +0 -0
  52. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/factors.py +0 -0
  53. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/hash.py +0 -0
  54. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/predict.py +0 -0
  55. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/render.py +0 -0
  56. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/replay.py +0 -0
  57. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/report.py +0 -0
  58. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/report_cli.py +0 -0
  59. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/store.py +0 -0
  60. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/synth.py +0 -0
  61. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/usable.py +0 -0
  62. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/import_cli.py +0 -0
  63. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/instance_checks.py +0 -0
  64. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/program_planner.py +0 -0
  65. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/remote/__init__.py +0 -0
  66. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/remote/auth.py +0 -0
  67. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/remote/checks.py +0 -0
  68. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/remote/cli.py +0 -0
  69. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/remote/mcp_client.py +0 -0
  70. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/render_cli.py +0 -0
  71. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/validate_program.py +0 -0
  72. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner.egg-info/dependency_links.txt +0 -0
  73. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner.egg-info/entry_points.txt +0 -0
  74. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner.egg-info/top_level.txt +0 -0
  75. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_bridge_serve.py +0 -0
  76. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_calibrate.py +0 -0
  77. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_cli.py +0 -0
  78. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_eval_baseline.py +0 -0
  79. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_eval_harness.py +0 -0
  80. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_eval_providers.py +0 -0
  81. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_eval_scorer.py +0 -0
  82. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_evaluate.py +0 -0
  83. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_example_validation.py +0 -0
  84. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_examples_integration.py +0 -0
  85. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_expand_replicates.py +0 -0
  86. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_hash_parity.py +0 -0
  87. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_history_report.py +0 -0
  88. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_import_cli.py +0 -0
  89. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_mcp_checks.py +0 -0
  90. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_planner_time_strings.py +0 -0
  91. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_predict.py +0 -0
  92. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_program_runner.py +0 -0
  93. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_remote.py +0 -0
  94. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_render_cli.py +0 -0
  95. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_replay.py +0 -0
  96. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_run_recorder.py +0 -0
  97. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_skill.py +0 -0
  98. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_synth.py +0 -0
  99. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_usable.py +0 -0
  100. {rhylthyme_cli_runner-0.3.0a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_validate_examples_ci.py +0 -0
  101. {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.0a0
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.1a0
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.3.0a0",
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.1a0",
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",
@@ -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.3.0a0"
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 INSTALL_HINT, InstrumentSetupError
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
- try:
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 = galago.load_workcell(workcell_source)
128
- except galago.WorkcellError as e:
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
- workcell, config_path or (auth.config_dir() / "bridges.json")
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
- {"name": t.name, "type": t.type, "status": "idle"}
151
- for t in workcell.tools.values()
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(workcell)
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=workcell,
224
+ workcell=view,
216
225
  tools=tools,
217
226
  allows_live=allow_live,
218
- version=getattr(galago, "__version__", ""),
227
+ version=version,
219
228
  on_error=lambda message: print(f"Bridge: {message}", file=out),
220
229
  submit=submit,
221
230
  )
@@ -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 tools "
114
- "(tool names, types, commands and params)"
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 tools;
130
- without it, commands are checked against each step's toolType.
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 galago's simulated "
286
- "mode; shows what will run and asks first"
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 (galago-tools servers,
339
- via rhylthyme-galago). Tools run in galago's simulated mode unless you pass
340
- --live, which lists the tools and commands and asks you to type 'live'
341
- before anything is configured; --confirm-live answers for scripts.
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 galago workcell and show it live on rhylthyme.com.
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 rhylthyme-galago
410
- (pip install "rhylthyme[galago]"). Only outbound HTTPS; tool addresses
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): from the tool with --workcell, else from a
489
- duration-like command param, else a default.
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,