rhylthyme-cli-runner 0.2.8a0__tar.gz → 0.3.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 (91) hide show
  1. {rhylthyme_cli_runner-0.2.8a0/src/rhylthyme_cli_runner.egg-info → rhylthyme_cli_runner-0.3.0a0}/PKG-INFO +2 -2
  2. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/setup.py +3 -3
  3. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/__init__.py +1 -1
  4. rhylthyme_cli_runner-0.3.0a0/src/rhylthyme_cli_runner/bridge_serve.py +273 -0
  5. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/cli.py +95 -0
  6. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/recorder.py +17 -0
  7. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/instruments.py +84 -0
  8. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/program_runner.py +185 -22
  9. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0/src/rhylthyme_cli_runner.egg-info}/PKG-INFO +2 -2
  10. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner.egg-info/SOURCES.txt +2 -0
  11. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner.egg-info/requires.txt +1 -1
  12. rhylthyme_cli_runner-0.3.0a0/tests/test_bridge_serve.py +293 -0
  13. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_instruments.py +258 -2
  14. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/LICENSE +0 -0
  15. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/README.md +0 -0
  16. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/pyproject.toml +0 -0
  17. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/setup.cfg +0 -0
  18. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/environment_icons.py +0 -0
  19. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/environment_loader.py +0 -0
  20. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/environment_schemas.py +0 -0
  21. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/eval/__init__.py +0 -0
  22. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/eval/cli.py +0 -0
  23. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/eval/compare.py +0 -0
  24. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/eval/gold.py +0 -0
  25. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/eval/harness.py +0 -0
  26. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/eval/llm.py +0 -0
  27. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/eval/matcher.py +0 -0
  28. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/eval/metrics.py +0 -0
  29. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/eval/patterns/__init__.py +0 -0
  30. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/eval/patterns/authoring_guide.md +0 -0
  31. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/eval/patterns/baseline.md +0 -0
  32. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/eval/patterns/baseline.py +0 -0
  33. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/eval/patterns/extract.py +0 -0
  34. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/eval/patterns/four_turn.py +0 -0
  35. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/eval/report.py +0 -0
  36. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/expand_replicates.py +0 -0
  37. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/__init__.py +0 -0
  38. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/calibrate.py +0 -0
  39. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/calibrate_cli.py +0 -0
  40. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/cli.py +0 -0
  41. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/evaluate.py +0 -0
  42. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/evaluate_cli.py +0 -0
  43. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/factors.py +0 -0
  44. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/hash.py +0 -0
  45. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/predict.py +0 -0
  46. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/render.py +0 -0
  47. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/replay.py +0 -0
  48. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/report.py +0 -0
  49. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/report_cli.py +0 -0
  50. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/store.py +0 -0
  51. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/synth.py +0 -0
  52. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/history/usable.py +0 -0
  53. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/import_cli.py +0 -0
  54. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/instance_checks.py +0 -0
  55. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/program_planner.py +0 -0
  56. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/remote/__init__.py +0 -0
  57. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/remote/auth.py +0 -0
  58. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/remote/checks.py +0 -0
  59. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/remote/cli.py +0 -0
  60. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/remote/mcp_client.py +0 -0
  61. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/render_cli.py +0 -0
  62. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner/validate_program.py +0 -0
  63. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner.egg-info/dependency_links.txt +0 -0
  64. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner.egg-info/entry_points.txt +0 -0
  65. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/src/rhylthyme_cli_runner.egg-info/top_level.txt +0 -0
  66. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_calibrate.py +0 -0
  67. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_cli.py +0 -0
  68. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_eval_baseline.py +0 -0
  69. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_eval_harness.py +0 -0
  70. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_eval_providers.py +0 -0
  71. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_eval_scorer.py +0 -0
  72. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_evaluate.py +0 -0
  73. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_example_validation.py +0 -0
  74. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_examples_integration.py +0 -0
  75. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_expand_replicates.py +0 -0
  76. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_hash_parity.py +0 -0
  77. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_history_report.py +0 -0
  78. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_import_cli.py +0 -0
  79. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_mcp_checks.py +0 -0
  80. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_planner_time_strings.py +0 -0
  81. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_predict.py +0 -0
  82. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_program_runner.py +0 -0
  83. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_remote.py +0 -0
  84. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_render_cli.py +0 -0
  85. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_replay.py +0 -0
  86. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_run_recorder.py +0 -0
  87. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_skill.py +0 -0
  88. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_synth.py +0 -0
  89. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_usable.py +0 -0
  90. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.0a0}/tests/test_validate_examples_ci.py +0 -0
  91. {rhylthyme_cli_runner-0.2.8a0 → rhylthyme_cli_runner-0.3.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.2.8a0
3
+ Version: 0.3.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
@@ -28,7 +28,7 @@ Requires-Dist: pyyaml>=6.0
28
28
  Requires-Dist: colorama>=0.4.0
29
29
  Requires-Dist: rhylthyme-spec>=0.2.1a0
30
30
  Provides-Extra: galago
31
- Requires-Dist: rhylthyme-galago>=0.1.0a0; extra == "galago"
31
+ Requires-Dist: rhylthyme-galago>=0.2.0a0; extra == "galago"
32
32
  Provides-Extra: dev
33
33
  Requires-Dist: pytest>=6.0.0; extra == "dev"
34
34
  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.2.8a0",
22
+ version="0.3.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",
@@ -41,8 +41,8 @@ setup(
41
41
  "rhylthyme-spec>=0.2.1a0",
42
42
  ],
43
43
  extras_require={
44
- # Instrument steps on galago-tools lab instruments (--workcell)
45
- "galago": ["rhylthyme-galago>=0.1.0a0"],
44
+ # Instrument steps on galago-tools lab instruments (--workcell, rhylthyme bridge)
45
+ "galago": ["rhylthyme-galago>=0.2.0a0"],
46
46
  "dev": [
47
47
  # Testing framework
48
48
  "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.2.8a0"
9
+ __version__ = "0.3.0a0"
10
10
  __author__ = "Rhylthyme Team"
11
11
  __description__ = "CLI runner for Rhylthyme real-time program schedules"
12
12
 
@@ -0,0 +1,273 @@
1
+ """
2
+ ``rhylthyme bridge --workcell lab.json`` with no program: wait for runs started
3
+ from rhylthyme.com (the web bridge, slice 3; design: rhylthyme-galago
4
+ docs/design/web-bridge.md).
5
+
6
+ The bridge stays listed as online on the Bridges page. A ``start_run`` command
7
+ names a program saved in the user's own library; this machine loads it,
8
+ checks it against the local workcell and refuses it unless it is safe to run
9
+ here, then runs it exactly as ``rhylthyme bridge PROGRAM`` would, and goes
10
+ back to waiting when the run completes or is aborted.
11
+
12
+ Live runs need two keys: the bridge must have been started with
13
+ ``--allow-live`` (a decision made at the lab machine), and the browser must
14
+ send the ``live`` the user typed after seeing the pre-flight summary. Before
15
+ a run is accepted, in either mode, its tools are configured and must report
16
+ ready; if not, the start is refused with the pre-flight report (tool addresses
17
+ scrubbed).
18
+ """
19
+
20
+ import json
21
+ import queue
22
+ import re
23
+ import sys
24
+ from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
25
+
26
+ from .instruments import INSTALL_HINT, InstrumentSetupError
27
+
28
+ UUID_RE = re.compile(
29
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I
30
+ )
31
+
32
+ Outcome = Dict[str, Any]
33
+
34
+
35
+ def _no(reason: str) -> Outcome:
36
+ return {"accepted": False, "reason": reason}
37
+
38
+
39
+ def _has_code_blocks(program: Mapping[str, Any]) -> bool:
40
+ for group in ("tracks", "trackTemplates"):
41
+ for track in program.get(group) or []:
42
+ for step in track.get("steps") or []:
43
+ if isinstance(step, Mapping) and step.get("codeBlock"):
44
+ return True
45
+ return False
46
+
47
+
48
+ def check_start(
49
+ args: Mapping[str, Any],
50
+ fetch_program: Callable[[str], Optional[Mapping[str, Any]]],
51
+ workcell_source: Any,
52
+ schema: Mapping[str, Any],
53
+ allow_live: bool = False,
54
+ ) -> Tuple[Outcome, Optional[Dict[str, Any]]]:
55
+ """
56
+ Decide whether a ``start_run`` may run here. Returns (outcome, program):
57
+ the program when accepted, None otherwise.
58
+ """
59
+ from .validate_program import perform_additional_validations, validate_program
60
+
61
+ mode = args.get("mode") or "simulated"
62
+ if mode not in ("simulated", "live"):
63
+ return _no(f"unknown mode {mode!r}"), None
64
+ if mode == "live":
65
+ if not allow_live:
66
+ return (
67
+ _no(
68
+ "this bridge does not accept live runs (start it with --allow-live)"
69
+ ),
70
+ None,
71
+ )
72
+ if args.get("confirm") != "live":
73
+ return _no("a live run needs the typed confirmation"), None
74
+ program_id = str(args.get("program_id") or "")
75
+ if not UUID_RE.match(program_id):
76
+ return _no("program_id must be the id of a program in your library"), None
77
+ row = fetch_program(program_id)
78
+ if not row or not isinstance(row.get("program_json"), dict):
79
+ return _no("no such program in your library"), None
80
+ program = json.loads(json.dumps(row["program_json"]))
81
+ if _has_code_blocks(program):
82
+ return _no("programs with code blocks cannot be started from the web"), None
83
+ is_valid, schema_errors = validate_program(program, dict(schema))
84
+ errors = list(schema_errors) + perform_additional_validations(
85
+ program, workcell=workcell_source
86
+ )
87
+ if not is_valid or errors:
88
+ shown = "; ".join(str(e) for e in errors[:3])
89
+ more = f" (+{len(errors) - 3} more)" if len(errors) > 3 else ""
90
+ return _no(f"the program is not valid on this workcell: {shown}{more}"), None
91
+ return {"accepted": True, "reason": ""}, program
92
+
93
+
94
+ def serve(
95
+ workcell_source: Any,
96
+ *,
97
+ schema_file: str,
98
+ time_scale: float = 1.0,
99
+ record: bool = True,
100
+ rest=None,
101
+ token_fn: Optional[Callable[[], str]] = None,
102
+ config_path=None,
103
+ run: Optional[Callable[..., Any]] = None,
104
+ max_runs: Optional[int] = None,
105
+ out=None,
106
+ allow_live: bool = False,
107
+ client_factory: Optional[Callable] = None,
108
+ ) -> int:
109
+ """Wait for runs from the web until Ctrl-C (or ``max_runs``). Returns runs started."""
110
+ 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
+
117
+ from .remote import auth
118
+ from .validate_program import load_program_file
119
+
120
+ if run is None:
121
+ from .program_runner import run_program
122
+
123
+ run = run_program
124
+ run_fn: Callable[..., Any] = run
125
+
126
+ try:
127
+ workcell = galago.load_workcell(workcell_source)
128
+ except galago.WorkcellError as e:
129
+ raise InstrumentSetupError(str(e)) from None
130
+ try:
131
+ if rest is None or token_fn is None:
132
+ creds = auth.load_credentials() or {}
133
+ if not creds.get("supabase_url"):
134
+ raise InstrumentSetupError(
135
+ "rhylthyme bridge needs you signed in: run `rhylthyme login`."
136
+ )
137
+ token_fn = token_fn or auth.access_token
138
+ rest = rest or B.SupabaseRest(
139
+ creds["supabase_url"], creds["supabase_anon_key"], token_fn
140
+ )
141
+ user_id = B.user_id_from_token(token_fn())
142
+ except (auth.AuthError, B.BridgeError) as e:
143
+ raise InstrumentSetupError(str(e)) from None
144
+
145
+ bridge_id = B.bridge_id_for(
146
+ workcell, config_path or (auth.config_dir() / "bridges.json")
147
+ )
148
+ schema = load_program_file(schema_file)
149
+ tools: List[Dict[str, str]] = [
150
+ {"name": t.name, "type": t.type, "status": "idle"}
151
+ for t in workcell.tools.values()
152
+ ]
153
+ starts: "queue.Queue[Tuple[Dict[str, Any], str, Any, bool]]" = queue.Queue()
154
+ scrub = B.scrubber(workcell)
155
+
156
+ def fetch_program(program_id: str) -> Optional[Mapping[str, Any]]:
157
+ rows = rest.select(
158
+ "programs",
159
+ f"id=eq.{program_id}&user_id=eq.{user_id}&select=id,name,program_json",
160
+ )
161
+ return rows[0] if rows else None
162
+
163
+ def submit(command: Dict[str, Any]) -> Outcome:
164
+ if not starts.empty():
165
+ return _no("a run is already starting")
166
+ args = command.get("args") or {}
167
+ try:
168
+ outcome, program = check_start(
169
+ args, fetch_program, workcell_source, schema, allow_live=allow_live
170
+ )
171
+ except B.BridgeError as e:
172
+ return _no(f"could not load the program: {e}")
173
+ if not outcome["accepted"] or program is None:
174
+ print(f"Refused a run from the web: {outcome['reason']}", file=out)
175
+ return outcome
176
+ live = args.get("mode") == "live"
177
+ # Configure the tools now, so a tool that is not ready is a refusal
178
+ # the browser sees rather than a run that never starts.
179
+ from .instruments import open_instruments
180
+
181
+ session = None
182
+ try:
183
+ kwargs: Dict[str, Any] = {"live": live}
184
+ if client_factory is not None:
185
+ kwargs["client_factory"] = client_factory
186
+ session = open_instruments(program, workcell_source, **kwargs)
187
+ session.prepare()
188
+ except InstrumentSetupError as e:
189
+ if session is not None:
190
+ session.shutdown()
191
+ reason = "tools not ready: " + scrub(str(e)).replace("\n", "; ")
192
+ print(f"Refused a run from the web: {reason}", file=out)
193
+ return _no(reason)
194
+ starts.put((program, args["program_id"], session, live))
195
+ if live:
196
+ print(
197
+ "\n*** LIVE run started from the web: real hardware will move. "
198
+ "Ctrl-C stops it. ***",
199
+ file=out,
200
+ )
201
+ print(
202
+ f"Starting {program.get('name')!r} from the web "
203
+ f"({'LIVE' if live else 'simulated'}).",
204
+ file=out,
205
+ )
206
+ return {"accepted": True, "reason": ""}
207
+
208
+ runs = 0
209
+ while max_runs is None or runs < max_runs:
210
+ idle = B.Publisher(
211
+ None,
212
+ rest=rest,
213
+ bridge_id=bridge_id,
214
+ user_id=user_id,
215
+ workcell=workcell,
216
+ tools=tools,
217
+ allows_live=allow_live,
218
+ version=getattr(galago, "__version__", ""),
219
+ on_error=lambda message: print(f"Bridge: {message}", file=out),
220
+ submit=submit,
221
+ )
222
+ try:
223
+ idle.start()
224
+ except B.BridgeError as e:
225
+ raise InstrumentSetupError(
226
+ f"Could not publish to rhylthyme.com: {e}"
227
+ ) from None
228
+ print(
229
+ f"Bridge {workcell.name!r} is waiting for runs from rhylthyme.com "
230
+ "(Bridges page). Ctrl-C stops it.",
231
+ file=out,
232
+ )
233
+ if allow_live:
234
+ print(
235
+ "LIVE runs may be started from the web (--allow-live); each needs "
236
+ "the user to type 'live' after the pre-flight summary.",
237
+ file=out,
238
+ )
239
+ try:
240
+ while True:
241
+ try:
242
+ program, program_id, session, live = starts.get(timeout=0.5)
243
+ break
244
+ except queue.Empty:
245
+ continue
246
+ finally:
247
+ idle.stop()
248
+ try:
249
+ run_fn(
250
+ None,
251
+ schema_file,
252
+ time_scale,
253
+ False, # validated in check_start
254
+ # auto_start=False: the clock runs as soon as the runner opens.
255
+ # (auto_start=True honours a program's manual start trigger and
256
+ # would wait for someone to press 's' at the lab machine.)
257
+ False,
258
+ None,
259
+ record=record,
260
+ factor_prompt=False,
261
+ workcell=workcell_source,
262
+ bridge=True,
263
+ program_data=program,
264
+ program_id=program_id,
265
+ exit_when_done=True,
266
+ live=live,
267
+ prepared_instruments=session,
268
+ bridge_allows_live=allow_live,
269
+ )
270
+ except SystemExit:
271
+ print("The run could not start; waiting for the next one.", file=out)
272
+ runs += 1
273
+ return runs
@@ -360,6 +360,101 @@ def run(
360
360
  )
361
361
 
362
362
 
363
+ @cli.command()
364
+ @click.argument("program_file", type=click.Path(exists=True), required=False)
365
+ @click.option(
366
+ "--workcell",
367
+ type=click.Path(exists=True, dir_okay=False),
368
+ required=True,
369
+ help="Workcell file mapping the program's instrument tools to galago-tools servers",
370
+ )
371
+ @click.option(
372
+ "--live",
373
+ is_flag=True,
374
+ help="Run on real hardware (asks first, as with rhylthyme run --live)",
375
+ )
376
+ @click.option(
377
+ "--confirm-live", is_flag=True, help="Answer the --live question in advance"
378
+ )
379
+ @click.option(
380
+ "--allow-live",
381
+ is_flag=True,
382
+ help=(
383
+ "Waiting for the web: also accept LIVE runs (each still needs the user "
384
+ "to type 'live' in the browser after the pre-flight summary)"
385
+ ),
386
+ )
387
+ @click.option(
388
+ "--time-scale", type=float, default=1.0, help="Time scale factor (default: 1.0)"
389
+ )
390
+ @click.option(
391
+ "--no-record",
392
+ is_flag=True,
393
+ help="Do not write a run record when the run ends",
394
+ )
395
+ def bridge(
396
+ program_file, workcell, live, confirm_live, allow_live, time_scale, no_record
397
+ ):
398
+ """
399
+ Run a program on a galago workcell and show it live on rhylthyme.com.
400
+
401
+ Without PROGRAM_FILE, wait for runs started from the Bridges page: a
402
+ program saved in your library, checked here against the workcell (no code
403
+ blocks; its tools must come up ready), run, then back to waiting. Runs are
404
+ simulated unless you pass --allow-live here AND the user types 'live' in
405
+ the browser. Ctrl-C stops.
406
+
407
+ Works like `rhylthyme run --workcell`, and while the run lasts keeps your
408
+ 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
+ retry or skip a failed step and abort; this machine checks and logs each
413
+ command, and Ctrl-C here always wins.
414
+ """
415
+ if program_file is None:
416
+ from .bridge_serve import serve
417
+ from .instruments import InstrumentSetupError
418
+
419
+ if live:
420
+ raise click.UsageError(
421
+ "--live applies to a PROGRAM_FILE run; to accept live runs "
422
+ "from the web, pass --allow-live."
423
+ )
424
+ try:
425
+ serve(
426
+ workcell,
427
+ schema_file=_default_schema_path(),
428
+ time_scale=time_scale,
429
+ record=not no_record,
430
+ allow_live=allow_live,
431
+ )
432
+ except InstrumentSetupError as e:
433
+ raise click.ClickException(str(e))
434
+ except KeyboardInterrupt:
435
+ click.echo("Bridge stopped.")
436
+ return
437
+ if allow_live:
438
+ raise click.UsageError(
439
+ "--allow-live is for waiting for runs from the web (no PROGRAM_FILE); "
440
+ "use --live to run PROGRAM_FILE on real hardware."
441
+ )
442
+ run_program(
443
+ program_file,
444
+ _default_schema_path(),
445
+ time_scale,
446
+ True,
447
+ False,
448
+ None,
449
+ record=not no_record,
450
+ factor_prompt=False,
451
+ workcell=workcell,
452
+ live=live,
453
+ confirm_live=confirm_live,
454
+ bridge=True,
455
+ )
456
+
457
+
363
458
  # Plan command
364
459
  @cli.command()
365
460
  @click.argument("input_file", type=click.Path(exists=True))
@@ -15,6 +15,7 @@ the events it emits are:
15
15
  ``program_resumed`` {time, wall_time, paused_seconds}
16
16
  ``program_aborted`` {time, reason} (reason goes to ``context.abortReason``)
17
17
  ``instrument_reply`` {step_id, time, ok, code, errorMessage, metadata}
18
+ ``remote_command`` {time, id, kind, args, accepted, reason} (-> ``context.remoteCommands``)
18
19
 
19
20
  All ``time`` values are the runner's program clock (an epoch float that
20
21
  advances at ``time_scale``); the record stores them as seconds from the
@@ -298,6 +299,22 @@ class RunRecorder:
298
299
  if data.get("metadata"):
299
300
  reply["metadata"] = copy.deepcopy(data["metadata"])
300
301
  log["replies"].append(reply)
302
+ elif event_type == "remote_command":
303
+ start = self._start_epoch()
304
+ self.context.setdefault("remoteCommands", []).append(
305
+ {
306
+ "id": data.get("id"),
307
+ "kind": data.get("kind"),
308
+ "args": copy.deepcopy(data.get("args") or {}),
309
+ "atSeconds": (
310
+ round(max(0.0, data["time"] - start), 3)
311
+ if start is not None and data.get("time") is not None
312
+ else None
313
+ ),
314
+ "accepted": bool(data.get("accepted")),
315
+ "reason": data.get("reason") or "",
316
+ }
317
+ )
301
318
  elif event_type == "program_aborted":
302
319
  self.context["abortReason"] = data.get("reason") or "aborted"
303
320
  elif event_type == "program_paused":
@@ -234,6 +234,90 @@ def open_instruments(
234
234
  return InstrumentSession(galago.InstrumentExecutor(workcell, **kwargs), tools)
235
235
 
236
236
 
237
+ def start_bridge(
238
+ runner,
239
+ session: "InstrumentSession",
240
+ program: Mapping[str, Any],
241
+ *,
242
+ rest=None,
243
+ token_fn: Optional[Callable[[], str]] = None,
244
+ config_path=None,
245
+ program_id: Optional[str] = None,
246
+ allows_live: bool = False,
247
+ ):
248
+ """
249
+ Start publishing this run to rhylthyme.com (``rhylthyme bridge``): the
250
+ user's own ``bridges`` / ``bridge_state`` rows, owner-only. Needs
251
+ ``rhylthyme login``. Returns the running Publisher; call ``stop()`` at the
252
+ end. Raises InstrumentSetupError if the site cannot be reached or written.
253
+ """
254
+ import rhylthyme_galago
255
+ from rhylthyme_galago import bridge as B
256
+
257
+ from .remote import auth
258
+
259
+ try:
260
+ if rest is None or token_fn is None:
261
+ creds = auth.load_credentials() or {}
262
+ if not creds.get("supabase_url"):
263
+ raise InstrumentSetupError(
264
+ "rhylthyme bridge needs you signed in: run `rhylthyme login`."
265
+ )
266
+ token_fn = token_fn or auth.access_token
267
+ rest = rest or B.SupabaseRest(
268
+ creds["supabase_url"], creds["supabase_anon_key"], token_fn
269
+ )
270
+ user_id = B.user_id_from_token(token_fn())
271
+ except (auth.AuthError, B.BridgeError) as e:
272
+ raise InstrumentSetupError(str(e)) from None
273
+
274
+ workcell = session.executor.workcell
275
+ bridge_id = B.bridge_id_for(
276
+ workcell, config_path or (auth.config_dir() / "bridges.json")
277
+ )
278
+ tools = [
279
+ {"name": c.tool, "type": workcell.tool(c.tool).type, "status": c.status.status}
280
+ for c in session.checks
281
+ ]
282
+
283
+ def on_error(message: str) -> None:
284
+ runner.status_message = f"Bridge: {message}"
285
+
286
+ def submit(command: Dict[str, Any], wait: float = 3.0) -> Optional[Dict[str, Any]]:
287
+ """Hand a browser command to the runner thread; wait for its decision."""
288
+ import json as _json
289
+ import time as _time
290
+
291
+ runner.command_queue.put("remote:" + _json.dumps(command))
292
+ deadline = _time.time() + wait
293
+ while _time.time() < deadline:
294
+ outcome = runner.remote_outcomes.get(command["id"])
295
+ if outcome is not None:
296
+ return outcome
297
+ _time.sleep(0.05)
298
+ return None
299
+
300
+ publisher = B.Publisher(
301
+ runner,
302
+ rest=rest,
303
+ bridge_id=bridge_id,
304
+ user_id=user_id,
305
+ workcell=workcell,
306
+ tools=tools,
307
+ program=program,
308
+ mode="live" if session.live else "simulated",
309
+ allows_live=allows_live,
310
+ version=getattr(rhylthyme_galago, "__version__", ""),
311
+ on_error=on_error,
312
+ submit=submit,
313
+ program_id=program_id,
314
+ )
315
+ try:
316
+ return publisher.start()
317
+ except B.BridgeError as e:
318
+ raise InstrumentSetupError(f"Could not publish to rhylthyme.com: {e}") from None
319
+
320
+
237
321
  def attach_instruments(
238
322
  runner,
239
323
  workcell_source,