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.
- ctrlrelay/__init__.py +33 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +89 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +740 -0
- ctrlrelay/bridge/telegram_handler.py +136 -0
- ctrlrelay/cli.py +3138 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +778 -0
- ctrlrelay/core/dispatcher.py +372 -0
- ctrlrelay/core/github.py +432 -0
- ctrlrelay/core/network.py +277 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +920 -0
- ctrlrelay/core/pr_verifier.py +230 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/question_expiry.py +193 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +642 -0
- ctrlrelay/core/worktree.py +893 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/gh_protocol.py +47 -0
- ctrlrelay/install.py +203 -0
- ctrlrelay/personalization/__init__.py +26 -0
- ctrlrelay/personalization/manager.py +1183 -0
- ctrlrelay/personalization/paths.py +143 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +1367 -0
- ctrlrelay/pipelines/post_merge.py +411 -0
- ctrlrelay/pipelines/secops.py +1018 -0
- ctrlrelay/pipelines/task.py +713 -0
- ctrlrelay/setup.py +617 -0
- ctrlrelay/templates/__init__.py +0 -0
- ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
- ctrlrelay/templates/launchd/__init__.py +0 -0
- ctrlrelay/templates/launchd/bridge.plist.template +37 -0
- ctrlrelay/templates/launchd/poller.plist.template +44 -0
- ctrlrelay/templates/systemd/__init__.py +0 -0
- ctrlrelay/templates/systemd/bridge.service.template +20 -0
- ctrlrelay/templates/systemd/poller.service.template +25 -0
- ctrlrelay/transports/__init__.py +34 -0
- ctrlrelay/transports/base.py +74 -0
- ctrlrelay/transports/file_mock.py +103 -0
- ctrlrelay/transports/socket_client.py +298 -0
- ctrlrelay-0.10.0.dist-info/METADATA +254 -0
- ctrlrelay-0.10.0.dist-info/RECORD +53 -0
- ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
- ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
- 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)
|