ctrlrelay 0.10.0__py3-none-any.whl

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 (53) hide show
  1. ctrlrelay/__init__.py +33 -0
  2. ctrlrelay/bridge/__init__.py +21 -0
  3. ctrlrelay/bridge/__main__.py +89 -0
  4. ctrlrelay/bridge/protocol.py +75 -0
  5. ctrlrelay/bridge/server.py +740 -0
  6. ctrlrelay/bridge/telegram_handler.py +136 -0
  7. ctrlrelay/cli.py +3138 -0
  8. ctrlrelay/core/__init__.py +54 -0
  9. ctrlrelay/core/audit.py +257 -0
  10. ctrlrelay/core/checkpoint.py +155 -0
  11. ctrlrelay/core/config.py +778 -0
  12. ctrlrelay/core/dispatcher.py +372 -0
  13. ctrlrelay/core/github.py +432 -0
  14. ctrlrelay/core/network.py +277 -0
  15. ctrlrelay/core/obs.py +118 -0
  16. ctrlrelay/core/poller.py +920 -0
  17. ctrlrelay/core/pr_verifier.py +230 -0
  18. ctrlrelay/core/pr_watcher.py +121 -0
  19. ctrlrelay/core/question_expiry.py +193 -0
  20. ctrlrelay/core/scheduler.py +337 -0
  21. ctrlrelay/core/state.py +642 -0
  22. ctrlrelay/core/worktree.py +893 -0
  23. ctrlrelay/dashboard/__init__.py +5 -0
  24. ctrlrelay/dashboard/client.py +159 -0
  25. ctrlrelay/gh_protocol.py +47 -0
  26. ctrlrelay/install.py +203 -0
  27. ctrlrelay/personalization/__init__.py +26 -0
  28. ctrlrelay/personalization/manager.py +1183 -0
  29. ctrlrelay/personalization/paths.py +143 -0
  30. ctrlrelay/pipelines/__init__.py +15 -0
  31. ctrlrelay/pipelines/base.py +50 -0
  32. ctrlrelay/pipelines/dev.py +1367 -0
  33. ctrlrelay/pipelines/post_merge.py +411 -0
  34. ctrlrelay/pipelines/secops.py +1018 -0
  35. ctrlrelay/pipelines/task.py +713 -0
  36. ctrlrelay/setup.py +617 -0
  37. ctrlrelay/templates/__init__.py +0 -0
  38. ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
  39. ctrlrelay/templates/launchd/__init__.py +0 -0
  40. ctrlrelay/templates/launchd/bridge.plist.template +37 -0
  41. ctrlrelay/templates/launchd/poller.plist.template +44 -0
  42. ctrlrelay/templates/systemd/__init__.py +0 -0
  43. ctrlrelay/templates/systemd/bridge.service.template +20 -0
  44. ctrlrelay/templates/systemd/poller.service.template +25 -0
  45. ctrlrelay/transports/__init__.py +34 -0
  46. ctrlrelay/transports/base.py +74 -0
  47. ctrlrelay/transports/file_mock.py +103 -0
  48. ctrlrelay/transports/socket_client.py +298 -0
  49. ctrlrelay-0.10.0.dist-info/METADATA +254 -0
  50. ctrlrelay-0.10.0.dist-info/RECORD +53 -0
  51. ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
  52. ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
  53. ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
ctrlrelay/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """ctrlrelay: Local-first orchestrator for headless coding agents."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from ctrlrelay.core import checkpoint
6
+
7
+
8
+ def _resolve_version() -> str:
9
+ try:
10
+ return version("ctrlrelay")
11
+ except PackageNotFoundError:
12
+ # Source checkout with no installed dist-info — e.g. a bare
13
+ # `pytest tests/` with pyproject's `pythonpath = ["src"]` as the
14
+ # only mechanism putting the package on sys.path. Fall back to
15
+ # parsing pyproject.toml so the drift-catcher test still sees a
16
+ # real version instead of a placeholder.
17
+ import tomllib
18
+ from pathlib import Path
19
+
20
+ pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
21
+ if pyproject.is_file():
22
+ try:
23
+ with pyproject.open("rb") as f:
24
+ return tomllib.load(f)["project"]["version"]
25
+ except (OSError, KeyError, tomllib.TOMLDecodeError):
26
+ pass
27
+ return "0.0.0+unknown"
28
+
29
+
30
+ __version__ = _resolve_version()
31
+
32
+ # Public API
33
+ __all__ = ["__version__", "checkpoint"]
@@ -0,0 +1,21 @@
1
+ """Bridge process for Telegram communication."""
2
+
3
+ from ctrlrelay.bridge.protocol import (
4
+ BridgeMessage,
5
+ BridgeOp,
6
+ ProtocolError,
7
+ parse_message,
8
+ serialize_message,
9
+ )
10
+ from ctrlrelay.bridge.server import BridgeServer
11
+ from ctrlrelay.bridge.telegram_handler import TelegramHandler
12
+
13
+ __all__ = [
14
+ "BridgeMessage",
15
+ "BridgeOp",
16
+ "BridgeServer",
17
+ "ProtocolError",
18
+ "TelegramHandler",
19
+ "parse_message",
20
+ "serialize_message",
21
+ ]
@@ -0,0 +1,89 @@
1
+ """Bridge process entry point for daemon mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import os
8
+ import signal
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from ctrlrelay.bridge.server import BridgeServer
13
+
14
+
15
+ def main() -> None:
16
+ parser = argparse.ArgumentParser(description="ctrlrelay Telegram bridge")
17
+ parser.add_argument("--socket-path", required=True, help="Unix socket path")
18
+ parser.add_argument(
19
+ "--bot-token-env",
20
+ default="CTRLRELAY_TELEGRAM_TOKEN",
21
+ help="Environment variable holding the Telegram bot token",
22
+ )
23
+ parser.add_argument("--chat-id", type=int, required=True, help="Telegram chat ID")
24
+ parser.add_argument(
25
+ "--state-db",
26
+ default=None,
27
+ help=(
28
+ "Path to the orchestrator state.db. When provided, orphan "
29
+ "Telegram replies route to persisted BLOCKED sessions in "
30
+ "pending_resumes. Required for the resume-via-Telegram flow."
31
+ ),
32
+ )
33
+ args = parser.parse_args()
34
+
35
+ bot_token = os.environ.get(args.bot_token_env)
36
+ if not bot_token:
37
+ print(
38
+ f"error: bot token env var '{args.bot_token_env}' is unset",
39
+ file=sys.stderr,
40
+ )
41
+ sys.exit(2)
42
+
43
+ state_db = None
44
+ if args.state_db:
45
+ from ctrlrelay.core.state import StateDB
46
+ state_db = StateDB(Path(args.state_db))
47
+
48
+ socket_path = Path(args.socket_path)
49
+ server = BridgeServer(
50
+ socket_path=socket_path,
51
+ bot_token=bot_token,
52
+ chat_id=args.chat_id,
53
+ state_db=state_db,
54
+ )
55
+
56
+ loop = asyncio.new_event_loop()
57
+ asyncio.set_event_loop(loop)
58
+
59
+ async def _run_server() -> None:
60
+ # Wrap start() in a finally that awaits stop() so the loop can't
61
+ # close before _telegram.close() and the socket unlink complete.
62
+ try:
63
+ await server.start()
64
+ finally:
65
+ await server.stop()
66
+
67
+ main_task = loop.create_task(_run_server())
68
+
69
+ def handle_signal(sig: int) -> None:
70
+ main_task.cancel()
71
+
72
+ for sig in (signal.SIGTERM, signal.SIGINT):
73
+ loop.add_signal_handler(sig, handle_signal, sig)
74
+
75
+ try:
76
+ loop.run_until_complete(main_task)
77
+ except asyncio.CancelledError:
78
+ pass
79
+ finally:
80
+ loop.close()
81
+ if state_db is not None:
82
+ try:
83
+ state_db.close()
84
+ except Exception:
85
+ pass
86
+
87
+
88
+ if __name__ == "__main__":
89
+ main()
@@ -0,0 +1,75 @@
1
+ """Bridge protocol message types and serialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel
10
+
11
+
12
+ class ProtocolError(Exception):
13
+ """Raised when protocol parsing fails."""
14
+
15
+
16
+ class BridgeOp(str, Enum):
17
+ """Bridge operation types."""
18
+
19
+ SEND = "send"
20
+ ASK = "ask"
21
+ ACK = "ack"
22
+ ANSWER = "answer"
23
+ PING = "ping"
24
+ PONG = "pong"
25
+ ERROR = "error"
26
+
27
+
28
+ class BridgeMessage(BaseModel):
29
+ """Message exchanged between orchestrator and bridge."""
30
+
31
+ op: BridgeOp
32
+ request_id: str | None = None
33
+
34
+ # send
35
+ text: str | None = None
36
+
37
+ # ask
38
+ question: str | None = None
39
+ options: list[str] | None = None
40
+ timeout: int | None = None
41
+
42
+ # ack
43
+ status: str | None = None
44
+
45
+ # answer
46
+ answer: str | None = None
47
+ answered_at: str | None = None
48
+
49
+ # error
50
+ error: str | None = None
51
+ message: str | None = None
52
+
53
+ # Correlation fields (optional; used for structured logging only).
54
+ session_id: str | None = None
55
+ repo: str | None = None
56
+ issue_number: int | None = None
57
+
58
+
59
+ def serialize_message(msg: BridgeMessage) -> str:
60
+ """Serialize message to newline-delimited JSON."""
61
+ data = msg.model_dump(exclude_none=True)
62
+ return json.dumps(data) + "\n"
63
+
64
+
65
+ def parse_message(line: str) -> BridgeMessage:
66
+ """Parse newline-delimited JSON to message."""
67
+ try:
68
+ data: dict[str, Any] = json.loads(line.strip())
69
+ except json.JSONDecodeError as e:
70
+ raise ProtocolError(f"Invalid JSON: {e}") from e
71
+
72
+ if "op" not in data:
73
+ raise ProtocolError("Missing 'op' field in message")
74
+
75
+ return BridgeMessage.model_validate(data)