rhylthyme-cli-runner 0.2.9a0__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.2.9a0/src/rhylthyme_cli_runner.egg-info → rhylthyme_cli_runner-0.4.0a0}/PKG-INFO +5 -3
  2. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/setup.py +6 -4
  3. {rhylthyme_cli_runner-0.2.9a0 → 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.4.0a0/src/rhylthyme_cli_runner/bridge_serve.py +282 -0
  6. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/cli.py +113 -14
  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.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/recorder.py +26 -1
  11. rhylthyme_cli_runner-0.4.0a0/src/rhylthyme_cli_runner/instruments.py +905 -0
  12. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/program_runner.py +275 -55
  13. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0/src/rhylthyme_cli_runner.egg-info}/PKG-INFO +5 -3
  14. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner.egg-info/SOURCES.txt +11 -0
  15. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner.egg-info/requires.txt +5 -2
  16. rhylthyme_cli_runner-0.4.0a0/tests/test_bridge_serve.py +293 -0
  17. rhylthyme_cli_runner-0.4.0a0/tests/test_drivers.py +452 -0
  18. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_instruments.py +259 -0
  19. rhylthyme_cli_runner-0.4.0a0/tests/test_labmcp.py +647 -0
  20. rhylthyme_cli_runner-0.4.0a0/tests/test_labmcp_bridge.py +374 -0
  21. rhylthyme_cli_runner-0.4.0a0/tests/test_phase_actions.py +325 -0
  22. rhylthyme_cli_runner-0.4.0a0/tests/test_safe_stops.py +269 -0
  23. rhylthyme_cli_runner-0.2.9a0/src/rhylthyme_cli_runner/instruments.py +0 -258
  24. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/LICENSE +0 -0
  25. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/README.md +0 -0
  26. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/pyproject.toml +0 -0
  27. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/setup.cfg +0 -0
  28. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/environment_icons.py +0 -0
  29. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/environment_loader.py +0 -0
  30. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/environment_schemas.py +0 -0
  31. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/__init__.py +0 -0
  32. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/cli.py +0 -0
  33. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/compare.py +0 -0
  34. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/gold.py +0 -0
  35. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/harness.py +0 -0
  36. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/llm.py +0 -0
  37. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/matcher.py +0 -0
  38. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/metrics.py +0 -0
  39. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/__init__.py +0 -0
  40. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/authoring_guide.md +0 -0
  41. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/baseline.md +0 -0
  42. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/baseline.py +0 -0
  43. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/extract.py +0 -0
  44. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/patterns/four_turn.py +0 -0
  45. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/eval/report.py +0 -0
  46. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/expand_replicates.py +0 -0
  47. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/__init__.py +0 -0
  48. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/calibrate.py +0 -0
  49. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/calibrate_cli.py +0 -0
  50. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/cli.py +0 -0
  51. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/evaluate.py +0 -0
  52. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/evaluate_cli.py +0 -0
  53. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/factors.py +0 -0
  54. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/hash.py +0 -0
  55. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/predict.py +0 -0
  56. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/render.py +0 -0
  57. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/replay.py +0 -0
  58. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/report.py +0 -0
  59. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/report_cli.py +0 -0
  60. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/store.py +0 -0
  61. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/synth.py +0 -0
  62. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/history/usable.py +0 -0
  63. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/import_cli.py +0 -0
  64. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/instance_checks.py +0 -0
  65. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/program_planner.py +0 -0
  66. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/remote/__init__.py +0 -0
  67. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/remote/auth.py +0 -0
  68. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/remote/checks.py +0 -0
  69. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/remote/cli.py +0 -0
  70. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/remote/mcp_client.py +0 -0
  71. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/render_cli.py +0 -0
  72. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner/validate_program.py +0 -0
  73. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner.egg-info/dependency_links.txt +0 -0
  74. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner.egg-info/entry_points.txt +0 -0
  75. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/src/rhylthyme_cli_runner.egg-info/top_level.txt +0 -0
  76. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_calibrate.py +0 -0
  77. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_cli.py +0 -0
  78. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_eval_baseline.py +0 -0
  79. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_eval_harness.py +0 -0
  80. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_eval_providers.py +0 -0
  81. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_eval_scorer.py +0 -0
  82. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_evaluate.py +0 -0
  83. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_example_validation.py +0 -0
  84. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_examples_integration.py +0 -0
  85. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_expand_replicates.py +0 -0
  86. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_hash_parity.py +0 -0
  87. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_history_report.py +0 -0
  88. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_import_cli.py +0 -0
  89. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_mcp_checks.py +0 -0
  90. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_planner_time_strings.py +0 -0
  91. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_predict.py +0 -0
  92. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_program_runner.py +0 -0
  93. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_remote.py +0 -0
  94. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_render_cli.py +0 -0
  95. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_replay.py +0 -0
  96. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_run_recorder.py +0 -0
  97. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_skill.py +0 -0
  98. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_synth.py +0 -0
  99. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_usable.py +0 -0
  100. {rhylthyme_cli_runner-0.2.9a0 → rhylthyme_cli_runner-0.4.0a0}/tests/test_validate_examples_ci.py +0 -0
  101. {rhylthyme_cli_runner-0.2.9a0 → 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.2.9a0
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
- Requires-Dist: rhylthyme-galago>=0.1.0a0; extra == "galago"
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.2.9a0",
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
- # 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
+ # 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.2.9a0"
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
+ ]