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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>Label</key>
|
|
6
|
+
<string>${LABEL_PREFIX}.ctrlrelay-bridge</string>
|
|
7
|
+
<key>ProgramArguments</key>
|
|
8
|
+
<array>
|
|
9
|
+
<string>${CTRLRELAY_BIN}</string>
|
|
10
|
+
<string>bridge</string>
|
|
11
|
+
<string>start</string>
|
|
12
|
+
<string>--foreground</string>
|
|
13
|
+
</array>
|
|
14
|
+
<key>EnvironmentVariables</key>
|
|
15
|
+
<dict>
|
|
16
|
+
<key>CTRLRELAY_TELEGRAM_TOKEN</key>
|
|
17
|
+
<string>${CTRLRELAY_TELEGRAM_TOKEN}</string>
|
|
18
|
+
<key>PATH</key>
|
|
19
|
+
<string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
|
20
|
+
<!-- Without PYTHONUNBUFFERED, ctrlrelay's stdout sits in Python's
|
|
21
|
+
stdio buffer instead of flushing line-by-line, making
|
|
22
|
+
`tail -f ~/.ctrlrelay/logs/bridge.log` lag noticeably. -->
|
|
23
|
+
<key>PYTHONUNBUFFERED</key>
|
|
24
|
+
<string>1</string>
|
|
25
|
+
</dict>
|
|
26
|
+
<key>WorkingDirectory</key>
|
|
27
|
+
<string>${WORKDIR}</string>
|
|
28
|
+
<key>RunAtLoad</key>
|
|
29
|
+
<true/>
|
|
30
|
+
<key>KeepAlive</key>
|
|
31
|
+
<true/>
|
|
32
|
+
<key>StandardOutPath</key>
|
|
33
|
+
<string>${HOME}/.ctrlrelay/logs/bridge.log</string>
|
|
34
|
+
<key>StandardErrorPath</key>
|
|
35
|
+
<string>${HOME}/.ctrlrelay/logs/bridge.error.log</string>
|
|
36
|
+
</dict>
|
|
37
|
+
</plist>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>Label</key>
|
|
6
|
+
<string>${LABEL_PREFIX}.ctrlrelay-poller</string>
|
|
7
|
+
<key>ProgramArguments</key>
|
|
8
|
+
<array>
|
|
9
|
+
<string>${CTRLRELAY_BIN}</string>
|
|
10
|
+
<string>poller</string>
|
|
11
|
+
<string>start</string>
|
|
12
|
+
<string>--foreground</string>
|
|
13
|
+
<string>--interval</string>
|
|
14
|
+
<string>${POLLER_INTERVAL}</string>
|
|
15
|
+
</array>
|
|
16
|
+
<key>EnvironmentVariables</key>
|
|
17
|
+
<dict>
|
|
18
|
+
<key>CTRLRELAY_TELEGRAM_TOKEN</key>
|
|
19
|
+
<string>${CTRLRELAY_TELEGRAM_TOKEN}</string>
|
|
20
|
+
<key>PATH</key>
|
|
21
|
+
<string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
|
22
|
+
<!-- Without PYTHONUNBUFFERED, ctrlrelay's stdout sits in Python's
|
|
23
|
+
stdio buffer instead of flushing line-by-line, making
|
|
24
|
+
`tail -f ~/.ctrlrelay/logs/poller.log` lag noticeably. -->
|
|
25
|
+
<key>PYTHONUNBUFFERED</key>
|
|
26
|
+
<string>1</string>
|
|
27
|
+
</dict>
|
|
28
|
+
<key>WorkingDirectory</key>
|
|
29
|
+
<string>${WORKDIR}</string>
|
|
30
|
+
<key>RunAtLoad</key>
|
|
31
|
+
<true/>
|
|
32
|
+
<key>KeepAlive</key>
|
|
33
|
+
<true/>
|
|
34
|
+
<!-- Give the in-process scheduler time to drain a running secops
|
|
35
|
+
sweep on stop (worktree cleanup can take ~120s). launchd's
|
|
36
|
+
default ExitTimeOut is 20s, which would SIGKILL mid-cleanup. -->
|
|
37
|
+
<key>ExitTimeOut</key>
|
|
38
|
+
<integer>180</integer>
|
|
39
|
+
<key>StandardOutPath</key>
|
|
40
|
+
<string>${HOME}/.ctrlrelay/logs/poller.log</string>
|
|
41
|
+
<key>StandardErrorPath</key>
|
|
42
|
+
<string>${HOME}/.ctrlrelay/logs/poller.error.log</string>
|
|
43
|
+
</dict>
|
|
44
|
+
</plist>
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=ctrlrelay Telegram bridge
|
|
3
|
+
After=network-online.target
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
Type=simple
|
|
7
|
+
WorkingDirectory=${WORKDIR}
|
|
8
|
+
Environment=CTRLRELAY_TELEGRAM_TOKEN=${CTRLRELAY_TELEGRAM_TOKEN}
|
|
9
|
+
# Without PYTHONUNBUFFERED, ctrlrelay's stdout sits in Python's stdio
|
|
10
|
+
# buffer instead of flushing line-by-line, making `tail -f` and
|
|
11
|
+
# `journalctl -f` lag noticeably.
|
|
12
|
+
Environment=PYTHONUNBUFFERED=1
|
|
13
|
+
ExecStart=${CTRLRELAY_BIN} bridge start --foreground
|
|
14
|
+
Restart=always
|
|
15
|
+
RestartSec=5
|
|
16
|
+
StandardOutput=append:%h/.ctrlrelay/logs/bridge.log
|
|
17
|
+
StandardError=append:%h/.ctrlrelay/logs/bridge.error.log
|
|
18
|
+
|
|
19
|
+
[Install]
|
|
20
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=ctrlrelay issue poller
|
|
3
|
+
After=network-online.target ctrlrelay-bridge.service
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
Type=simple
|
|
7
|
+
WorkingDirectory=${WORKDIR}
|
|
8
|
+
Environment=CTRLRELAY_TELEGRAM_TOKEN=${CTRLRELAY_TELEGRAM_TOKEN}
|
|
9
|
+
# Without PYTHONUNBUFFERED, ctrlrelay's stdout sits in Python's stdio
|
|
10
|
+
# buffer instead of flushing line-by-line, making `tail -f` and
|
|
11
|
+
# `journalctl -f` lag noticeably.
|
|
12
|
+
Environment=PYTHONUNBUFFERED=1
|
|
13
|
+
ExecStart=${CTRLRELAY_BIN} poller start --foreground --interval ${POLLER_INTERVAL}
|
|
14
|
+
Restart=always
|
|
15
|
+
RestartSec=5
|
|
16
|
+
# Give the in-process scheduler time to drain a running secops sweep
|
|
17
|
+
# on stop (worktree cleanup can take ~120s). systemd's default
|
|
18
|
+
# TimeoutStopSec is 90s, which would SIGKILL the poller mid-cleanup
|
|
19
|
+
# and leak git worktree admin state.
|
|
20
|
+
TimeoutStopSec=180
|
|
21
|
+
StandardOutput=append:%h/.ctrlrelay/logs/poller.log
|
|
22
|
+
StandardError=append:%h/.ctrlrelay/logs/poller.error.log
|
|
23
|
+
|
|
24
|
+
[Install]
|
|
25
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Transport abstraction for orchestrator communication."""
|
|
2
|
+
|
|
3
|
+
from ctrlrelay.core.config import TransportConfig, TransportType
|
|
4
|
+
from ctrlrelay.transports.base import Transport, TransportError
|
|
5
|
+
from ctrlrelay.transports.file_mock import FileMockTransport
|
|
6
|
+
from ctrlrelay.transports.socket_client import SocketTransport
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_transport(config: TransportConfig) -> Transport:
|
|
10
|
+
"""Create transport instance from config."""
|
|
11
|
+
if config.type == TransportType.FILE_MOCK:
|
|
12
|
+
assert config.file_mock is not None
|
|
13
|
+
return FileMockTransport(
|
|
14
|
+
inbox=config.file_mock.inbox,
|
|
15
|
+
outbox=config.file_mock.outbox,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if config.type == TransportType.TELEGRAM:
|
|
19
|
+
assert config.telegram is not None
|
|
20
|
+
return SocketTransport(
|
|
21
|
+
socket_path=config.telegram.socket_path,
|
|
22
|
+
ask_timeout_seconds=config.telegram.ask_timeout_seconds,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
raise TransportError(f"Unknown transport type: {config.type}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"FileMockTransport",
|
|
30
|
+
"SocketTransport",
|
|
31
|
+
"Transport",
|
|
32
|
+
"TransportError",
|
|
33
|
+
"get_transport",
|
|
34
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Transport protocol definition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TransportError(Exception):
|
|
9
|
+
"""Raised when transport operations fail."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TransportUnknownDeliveryError(TransportError):
|
|
13
|
+
"""Raised when a request may or may not have been delivered.
|
|
14
|
+
|
|
15
|
+
Delivery is only ever *confirmed* by the far side's acknowledgement.
|
|
16
|
+
Everything else divides in two: we know the bytes never left (a
|
|
17
|
+
definite failure), or we do not (unknown). Enumerating individual
|
|
18
|
+
ambiguous cases does not converge — the honest discriminator is
|
|
19
|
+
whether anything was written at all.
|
|
20
|
+
|
|
21
|
+
Subclasses TransportError so existing handlers are unaffected.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TransportTimeoutError(TransportUnknownDeliveryError):
|
|
26
|
+
"""Sent, but no reply arrived in time.
|
|
27
|
+
|
|
28
|
+
A specific unknown: the request went out and the far side may still
|
|
29
|
+
act on it after we stop waiting.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@runtime_checkable
|
|
34
|
+
class Transport(Protocol):
|
|
35
|
+
"""Protocol for orchestrator-to-human communication.
|
|
36
|
+
|
|
37
|
+
Implementations accept optional correlation kwargs (``session_id``,
|
|
38
|
+
``repo``, ``issue_number``) used solely for structured logging — they are
|
|
39
|
+
never part of the wire payload.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
async def send(
|
|
43
|
+
self,
|
|
44
|
+
message: str,
|
|
45
|
+
*,
|
|
46
|
+
session_id: str | None = None,
|
|
47
|
+
repo: str | None = None,
|
|
48
|
+
issue_number: int | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Send a one-way message (no response expected)."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
async def ask(
|
|
54
|
+
self,
|
|
55
|
+
question: str,
|
|
56
|
+
options: list[str] | None = None,
|
|
57
|
+
timeout: int | None = None,
|
|
58
|
+
*,
|
|
59
|
+
session_id: str | None = None,
|
|
60
|
+
repo: str | None = None,
|
|
61
|
+
issue_number: int | None = None,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Ask a question and wait for response.
|
|
64
|
+
|
|
65
|
+
``timeout`` of ``None`` means "use the implementation's configured
|
|
66
|
+
default" — callers that have no opinion must not pin a literal here,
|
|
67
|
+
or the operator's ``ask_timeout_seconds`` setting is silently
|
|
68
|
+
bypassed.
|
|
69
|
+
"""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
async def close(self) -> None:
|
|
73
|
+
"""Close the transport connection."""
|
|
74
|
+
...
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""File-based mock transport for testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ctrlrelay.core.obs import get_logger, hash_text, log_event
|
|
11
|
+
from ctrlrelay.transports.base import TransportError
|
|
12
|
+
|
|
13
|
+
_logger = get_logger("transport.file_mock")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FileMockTransport:
|
|
17
|
+
"""Transport that reads/writes to files for testing."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
inbox: Path,
|
|
22
|
+
outbox: Path,
|
|
23
|
+
ask_timeout_seconds: int = 300,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.inbox = inbox
|
|
26
|
+
self.outbox = outbox
|
|
27
|
+
self.ask_timeout_seconds = ask_timeout_seconds
|
|
28
|
+
|
|
29
|
+
async def send(
|
|
30
|
+
self,
|
|
31
|
+
message: str,
|
|
32
|
+
*,
|
|
33
|
+
session_id: str | None = None,
|
|
34
|
+
repo: str | None = None,
|
|
35
|
+
issue_number: int | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Write message to outbox file."""
|
|
38
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
39
|
+
with self.outbox.open("a") as f:
|
|
40
|
+
f.write(f"[{timestamp}] {message}\n")
|
|
41
|
+
|
|
42
|
+
async def ask(
|
|
43
|
+
self,
|
|
44
|
+
question: str,
|
|
45
|
+
options: list[str] | None = None,
|
|
46
|
+
timeout: int | None = None,
|
|
47
|
+
*,
|
|
48
|
+
session_id: str | None = None,
|
|
49
|
+
repo: str | None = None,
|
|
50
|
+
issue_number: int | None = None,
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Write question to outbox and poll inbox for answer."""
|
|
53
|
+
if timeout is None:
|
|
54
|
+
timeout = self.ask_timeout_seconds
|
|
55
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
56
|
+
opts = f" [{'/'.join(options)}]" if options else ""
|
|
57
|
+
with self.outbox.open("a") as f:
|
|
58
|
+
f.write(f"[{timestamp}] QUESTION: {question}{opts}\n")
|
|
59
|
+
|
|
60
|
+
# Logged only after the outbox write, and without the question text:
|
|
61
|
+
# hash + length are enough to correlate, and keep operator content
|
|
62
|
+
# out of the log stream.
|
|
63
|
+
log_event(
|
|
64
|
+
_logger,
|
|
65
|
+
"dev.question.posted",
|
|
66
|
+
session_id=session_id,
|
|
67
|
+
repo=repo,
|
|
68
|
+
issue_number=issue_number,
|
|
69
|
+
transport="file_mock",
|
|
70
|
+
destination=str(self.outbox),
|
|
71
|
+
question_length=len(question),
|
|
72
|
+
question_hash=hash_text(question),
|
|
73
|
+
options=options,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
sent_at = time.monotonic()
|
|
77
|
+
start = asyncio.get_event_loop().time()
|
|
78
|
+
while True:
|
|
79
|
+
content = self.inbox.read_text().strip()
|
|
80
|
+
if content:
|
|
81
|
+
self.inbox.write_text("")
|
|
82
|
+
answer = content.split("\n")[0].strip()
|
|
83
|
+
log_event(
|
|
84
|
+
_logger,
|
|
85
|
+
"dev.answer.received",
|
|
86
|
+
session_id=session_id,
|
|
87
|
+
repo=repo,
|
|
88
|
+
issue_number=issue_number,
|
|
89
|
+
transport="file_mock",
|
|
90
|
+
answer_length=len(answer),
|
|
91
|
+
answer_hash=hash_text(answer),
|
|
92
|
+
elapsed_ms=int((time.monotonic() - sent_at) * 1000),
|
|
93
|
+
)
|
|
94
|
+
return answer
|
|
95
|
+
|
|
96
|
+
if asyncio.get_event_loop().time() - start > timeout:
|
|
97
|
+
raise TransportError(f"ask() timeout after {timeout}s")
|
|
98
|
+
|
|
99
|
+
await asyncio.sleep(0.5)
|
|
100
|
+
|
|
101
|
+
async def close(self) -> None:
|
|
102
|
+
"""No-op for file transport."""
|
|
103
|
+
pass
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Unix socket transport client for bridge communication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ctrlrelay.bridge.protocol import (
|
|
12
|
+
BridgeMessage,
|
|
13
|
+
BridgeOp,
|
|
14
|
+
ProtocolError,
|
|
15
|
+
parse_message,
|
|
16
|
+
serialize_message,
|
|
17
|
+
)
|
|
18
|
+
from ctrlrelay.core.obs import get_logger, hash_text, log_event
|
|
19
|
+
from ctrlrelay.transports.base import (
|
|
20
|
+
TransportError,
|
|
21
|
+
TransportTimeoutError,
|
|
22
|
+
TransportUnknownDeliveryError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_logger = get_logger("transport.socket")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SocketTransport:
|
|
29
|
+
"""Transport that connects to bridge via Unix socket."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
socket_path: Path,
|
|
34
|
+
ask_timeout_seconds: int = 300,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.socket_path = socket_path
|
|
37
|
+
# Per-instance default for ask(). Callers construct this from
|
|
38
|
+
# ``transport.telegram.ask_timeout_seconds`` so a single operator
|
|
39
|
+
# setting governs every pipeline's BLOCKED wait; the 300s fallback
|
|
40
|
+
# only applies to ad-hoc construction in tests and one-shot CLI
|
|
41
|
+
# helpers that never ask.
|
|
42
|
+
self.ask_timeout_seconds = ask_timeout_seconds
|
|
43
|
+
self._reader: asyncio.StreamReader | None = None
|
|
44
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
45
|
+
self._pending: dict[str, asyncio.Future[BridgeMessage]] = {}
|
|
46
|
+
self._on_ack: dict[str, Callable[[], None]] = {}
|
|
47
|
+
self._receive_task: asyncio.Task | None = None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def connected(self) -> bool:
|
|
51
|
+
return self._writer is not None and not self._writer.is_closing()
|
|
52
|
+
|
|
53
|
+
async def connect(self) -> None:
|
|
54
|
+
"""Connect to bridge socket."""
|
|
55
|
+
try:
|
|
56
|
+
self._reader, self._writer = await asyncio.open_unix_connection(
|
|
57
|
+
str(self.socket_path)
|
|
58
|
+
)
|
|
59
|
+
self._receive_task = asyncio.create_task(self._receive_loop())
|
|
60
|
+
except (OSError, ConnectionRefusedError) as e:
|
|
61
|
+
raise TransportError(f"Failed to connect to bridge: {e}") from e
|
|
62
|
+
|
|
63
|
+
async def _receive_loop(self) -> None:
|
|
64
|
+
"""Background task to receive messages from bridge."""
|
|
65
|
+
assert self._reader is not None
|
|
66
|
+
try:
|
|
67
|
+
while True:
|
|
68
|
+
line = await self._reader.readline()
|
|
69
|
+
if not line:
|
|
70
|
+
break
|
|
71
|
+
try:
|
|
72
|
+
msg = parse_message(line.decode())
|
|
73
|
+
if msg.request_id and msg.request_id in self._pending:
|
|
74
|
+
# The bridge sends two messages back for an ASK:
|
|
75
|
+
# an intermediate ACK(status="pending") immediately
|
|
76
|
+
# after queuing for Telegram, then an ANSWER (or
|
|
77
|
+
# ERROR) once the operator replies. Resolving the
|
|
78
|
+
# future on the first ACK collapses the wait
|
|
79
|
+
# prematurely — caller sees "Unexpected response:
|
|
80
|
+
# BridgeOp.ACK" and throws. Skip those to keep
|
|
81
|
+
# waiting. Terminal ACKs (status="sent" from SEND)
|
|
82
|
+
# remain the final response and resolve normally.
|
|
83
|
+
if msg.op == BridgeOp.ACK and msg.status == "pending":
|
|
84
|
+
# This ACK is also the only proof the bridge got
|
|
85
|
+
# the question in front of the operator, so it is
|
|
86
|
+
# what "posted" is allowed to mean.
|
|
87
|
+
callback = self._on_ack.get(msg.request_id)
|
|
88
|
+
if callback is not None:
|
|
89
|
+
callback()
|
|
90
|
+
continue
|
|
91
|
+
self._pending[msg.request_id].set_result(msg)
|
|
92
|
+
except ProtocolError:
|
|
93
|
+
pass
|
|
94
|
+
except asyncio.CancelledError:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
async def _send_message(self, msg: BridgeMessage) -> None:
|
|
98
|
+
"""Send message to bridge.
|
|
99
|
+
|
|
100
|
+
Raises ``TransportError`` while nothing has been written — a
|
|
101
|
+
definite non-delivery — and ``TransportUnknownDeliveryError``
|
|
102
|
+
once bytes may have reached the far side. `drain()` failing does
|
|
103
|
+
not mean the bridge never saw the request: `write()` has already
|
|
104
|
+
handed the data to the transport, so the ASK may well have been
|
|
105
|
+
received and acted on.
|
|
106
|
+
"""
|
|
107
|
+
if not self.connected:
|
|
108
|
+
raise TransportError("Transport not connected")
|
|
109
|
+
assert self._writer is not None
|
|
110
|
+
data = serialize_message(msg).encode()
|
|
111
|
+
try:
|
|
112
|
+
self._writer.write(data)
|
|
113
|
+
except Exception as e:
|
|
114
|
+
raise TransportError(f"Write failed: {e}") from e
|
|
115
|
+
try:
|
|
116
|
+
await self._writer.drain()
|
|
117
|
+
except Exception as e:
|
|
118
|
+
raise TransportUnknownDeliveryError(f"Drain failed: {e}") from e
|
|
119
|
+
|
|
120
|
+
async def _send_and_wait(
|
|
121
|
+
self,
|
|
122
|
+
msg: BridgeMessage,
|
|
123
|
+
timeout: int,
|
|
124
|
+
*,
|
|
125
|
+
on_ack: Callable[[], None] | None = None,
|
|
126
|
+
) -> BridgeMessage:
|
|
127
|
+
"""Send message and wait for response.
|
|
128
|
+
|
|
129
|
+
``on_ack`` fires from the receive loop when the bridge sends its
|
|
130
|
+
intermediate ACK — the point at which the request is known to have
|
|
131
|
+
been accepted, as opposed to merely written to the socket.
|
|
132
|
+
"""
|
|
133
|
+
assert msg.request_id is not None
|
|
134
|
+
future: asyncio.Future[BridgeMessage] = asyncio.get_event_loop().create_future()
|
|
135
|
+
self._pending[msg.request_id] = future
|
|
136
|
+
if on_ack is not None:
|
|
137
|
+
self._on_ack[msg.request_id] = on_ack
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
await self._send_message(msg)
|
|
141
|
+
return await asyncio.wait_for(future, timeout=timeout)
|
|
142
|
+
except asyncio.TimeoutError as e:
|
|
143
|
+
raise TransportTimeoutError("Timeout waiting for response") from e
|
|
144
|
+
finally:
|
|
145
|
+
self._pending.pop(msg.request_id, None)
|
|
146
|
+
self._on_ack.pop(msg.request_id, None)
|
|
147
|
+
|
|
148
|
+
async def send(
|
|
149
|
+
self,
|
|
150
|
+
message: str,
|
|
151
|
+
*,
|
|
152
|
+
session_id: str | None = None,
|
|
153
|
+
repo: str | None = None,
|
|
154
|
+
issue_number: int | None = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Send a one-way message."""
|
|
157
|
+
request_id = f"r-{uuid.uuid4().hex[:8]}"
|
|
158
|
+
msg = BridgeMessage(
|
|
159
|
+
op=BridgeOp.SEND,
|
|
160
|
+
request_id=request_id,
|
|
161
|
+
text=message,
|
|
162
|
+
session_id=session_id,
|
|
163
|
+
repo=repo,
|
|
164
|
+
issue_number=issue_number,
|
|
165
|
+
)
|
|
166
|
+
await self._send_message(msg)
|
|
167
|
+
|
|
168
|
+
async def ask(
|
|
169
|
+
self,
|
|
170
|
+
question: str,
|
|
171
|
+
options: list[str] | None = None,
|
|
172
|
+
timeout: int | None = None,
|
|
173
|
+
*,
|
|
174
|
+
session_id: str | None = None,
|
|
175
|
+
repo: str | None = None,
|
|
176
|
+
issue_number: int | None = None,
|
|
177
|
+
) -> str:
|
|
178
|
+
"""Ask a question and wait for response."""
|
|
179
|
+
if timeout is None:
|
|
180
|
+
timeout = self.ask_timeout_seconds
|
|
181
|
+
request_id = f"r-{uuid.uuid4().hex[:8]}"
|
|
182
|
+
msg = BridgeMessage(
|
|
183
|
+
op=BridgeOp.ASK,
|
|
184
|
+
request_id=request_id,
|
|
185
|
+
question=question,
|
|
186
|
+
options=options,
|
|
187
|
+
timeout=timeout,
|
|
188
|
+
session_id=session_id,
|
|
189
|
+
repo=repo,
|
|
190
|
+
issue_number=issue_number,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# The question itself never reaches the log stream: these records go
|
|
194
|
+
# to stdout and are captured into ~/.ctrlrelay/logs, where operator
|
|
195
|
+
# content would sit in plaintext with no retention policy. The hash
|
|
196
|
+
# is enough to correlate one question across poller and bridge.
|
|
197
|
+
common = {
|
|
198
|
+
"session_id": session_id,
|
|
199
|
+
"repo": repo,
|
|
200
|
+
"issue_number": issue_number,
|
|
201
|
+
"transport": "socket",
|
|
202
|
+
"destination": str(self.socket_path),
|
|
203
|
+
"request_id": request_id,
|
|
204
|
+
"question_length": len(question),
|
|
205
|
+
"question_hash": hash_text(question),
|
|
206
|
+
"options": options,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
posted = False
|
|
210
|
+
|
|
211
|
+
def _mark_posted() -> None:
|
|
212
|
+
# Fired by the receive loop on the bridge's ACK, which is the
|
|
213
|
+
# first moment the question is known to have reached Telegram.
|
|
214
|
+
# Logging before the write would claim a delivery that a failed
|
|
215
|
+
# socket write or a Telegram outage never made.
|
|
216
|
+
nonlocal posted
|
|
217
|
+
posted = True
|
|
218
|
+
log_event(_logger, "dev.question.posted", **common)
|
|
219
|
+
|
|
220
|
+
sent_at = time.monotonic()
|
|
221
|
+
try:
|
|
222
|
+
response = await self._send_and_wait(msg, timeout, on_ack=_mark_posted)
|
|
223
|
+
except Exception as e:
|
|
224
|
+
if not posted:
|
|
225
|
+
# A timeout is not a failed delivery. The bridge ACKs only
|
|
226
|
+
# after Telegram has accepted the message, so a post slower
|
|
227
|
+
# than our wait leaves us timing out first and the bridge
|
|
228
|
+
# succeeding afterwards — logging "post_failed" there would
|
|
229
|
+
# contradict the bridge's own "posted", which is the same
|
|
230
|
+
# false-claim problem this change exists to remove, just
|
|
231
|
+
# pointing the other way.
|
|
232
|
+
# Only claim a definite failure when the request
|
|
233
|
+
# provably never left. Anything past the write is
|
|
234
|
+
# unknown: no ACK arrived, but the bridge may have
|
|
235
|
+
# received and acted on it regardless.
|
|
236
|
+
unknown = isinstance(e, TransportUnknownDeliveryError)
|
|
237
|
+
log_event(
|
|
238
|
+
_logger,
|
|
239
|
+
"dev.question.post_unknown"
|
|
240
|
+
if unknown
|
|
241
|
+
else "dev.question.post_failed",
|
|
242
|
+
**common,
|
|
243
|
+
reason=type(e).__name__ if unknown else "send_failed",
|
|
244
|
+
error=str(e)[:200],
|
|
245
|
+
)
|
|
246
|
+
raise
|
|
247
|
+
|
|
248
|
+
if response.op == BridgeOp.ERROR:
|
|
249
|
+
if not posted:
|
|
250
|
+
# The bridge knows whether Telegram answered; we do not.
|
|
251
|
+
# Re-deriving it here is what produced contradictory
|
|
252
|
+
# pairs — post_unknown on the bridge, post_failed on the
|
|
253
|
+
# transport, same request_id.
|
|
254
|
+
bridge_unknown = response.error == "telegram_delivery_unknown"
|
|
255
|
+
log_event(
|
|
256
|
+
_logger,
|
|
257
|
+
"dev.question.post_unknown"
|
|
258
|
+
if bridge_unknown
|
|
259
|
+
else "dev.question.post_failed",
|
|
260
|
+
**common,
|
|
261
|
+
reason="bridge_error",
|
|
262
|
+
error=str(response.message)[:200],
|
|
263
|
+
)
|
|
264
|
+
raise TransportError(f"Bridge error: {response.message}")
|
|
265
|
+
if response.op == BridgeOp.ANSWER and response.answer:
|
|
266
|
+
if not posted:
|
|
267
|
+
# An answer proves the question was posted even if the ACK
|
|
268
|
+
# never arrived (older bridge, or the ACK was lost).
|
|
269
|
+
_mark_posted()
|
|
270
|
+
log_event(
|
|
271
|
+
_logger,
|
|
272
|
+
"dev.answer.received",
|
|
273
|
+
session_id=session_id,
|
|
274
|
+
repo=repo,
|
|
275
|
+
issue_number=issue_number,
|
|
276
|
+
transport="socket",
|
|
277
|
+
request_id=request_id,
|
|
278
|
+
answer_length=len(response.answer),
|
|
279
|
+
answer_hash=hash_text(response.answer),
|
|
280
|
+
elapsed_ms=int((time.monotonic() - sent_at) * 1000),
|
|
281
|
+
)
|
|
282
|
+
return response.answer
|
|
283
|
+
|
|
284
|
+
raise TransportError(f"Unexpected response: {response.op}")
|
|
285
|
+
|
|
286
|
+
async def close(self) -> None:
|
|
287
|
+
"""Close the connection."""
|
|
288
|
+
if self._receive_task:
|
|
289
|
+
self._receive_task.cancel()
|
|
290
|
+
try:
|
|
291
|
+
await self._receive_task
|
|
292
|
+
except asyncio.CancelledError:
|
|
293
|
+
pass
|
|
294
|
+
if self._writer:
|
|
295
|
+
self._writer.close()
|
|
296
|
+
await self._writer.wait_closed()
|
|
297
|
+
self._reader = None
|
|
298
|
+
self._writer = None
|