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