ctrlrelay 0.1.5__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 +8 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +69 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +285 -0
- ctrlrelay/bridge/telegram_handler.py +117 -0
- ctrlrelay/cli.py +1449 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +291 -0
- ctrlrelay/core/dispatcher.py +202 -0
- ctrlrelay/core/github.py +272 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +319 -0
- ctrlrelay/core/pr_verifier.py +177 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +167 -0
- ctrlrelay/core/worktree.py +673 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +562 -0
- ctrlrelay/pipelines/post_merge.py +279 -0
- ctrlrelay/pipelines/secops.py +379 -0
- ctrlrelay/transports/__init__.py +33 -0
- ctrlrelay/transports/base.py +47 -0
- ctrlrelay/transports/file_mock.py +94 -0
- ctrlrelay/transports/socket_client.py +180 -0
- ctrlrelay-0.1.5.dist-info/METADATA +251 -0
- ctrlrelay-0.1.5.dist-info/RECORD +36 -0
- ctrlrelay-0.1.5.dist-info/WHEEL +4 -0
- ctrlrelay-0.1.5.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.1.5.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Dashboard client for event push and heartbeat."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from ctrlrelay import __version__
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HeartbeatPayload(BaseModel):
|
|
18
|
+
"""Payload for heartbeat endpoint."""
|
|
19
|
+
|
|
20
|
+
node_id: str
|
|
21
|
+
timestamp: str = ""
|
|
22
|
+
version: str = __version__
|
|
23
|
+
uptime_seconds: int = 0
|
|
24
|
+
platform: str = ""
|
|
25
|
+
active_sessions: list[dict[str, Any]] = []
|
|
26
|
+
last_github_poll: str | None = None
|
|
27
|
+
last_github_poll_status: str = "ok"
|
|
28
|
+
repos_configured: int = 0
|
|
29
|
+
repos_active: int = 0
|
|
30
|
+
|
|
31
|
+
def model_post_init(self, __context: Any) -> None:
|
|
32
|
+
if not self.timestamp:
|
|
33
|
+
object.__setattr__(self, "timestamp", datetime.now(timezone.utc).isoformat())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class EventPayload(BaseModel):
|
|
37
|
+
"""Payload for event endpoint."""
|
|
38
|
+
|
|
39
|
+
level: str # info, warning, error
|
|
40
|
+
pipeline: str # secops, dev
|
|
41
|
+
repo: str
|
|
42
|
+
message: str
|
|
43
|
+
session_id: str | None = None
|
|
44
|
+
timestamp: str = ""
|
|
45
|
+
details: dict[str, Any] = {}
|
|
46
|
+
|
|
47
|
+
def model_post_init(self, __context: Any) -> None:
|
|
48
|
+
if not self.timestamp:
|
|
49
|
+
object.__setattr__(
|
|
50
|
+
self, "timestamp", datetime.now(timezone.utc).isoformat()
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class DashboardClient:
|
|
56
|
+
"""Client for dashboard API with offline queue."""
|
|
57
|
+
|
|
58
|
+
url: str
|
|
59
|
+
auth_token: str
|
|
60
|
+
node_id: str
|
|
61
|
+
queue_dir: Path | None = None
|
|
62
|
+
timeout: int = 30
|
|
63
|
+
max_retries: int = 3
|
|
64
|
+
_queue: list[dict[str, Any]] = field(default_factory=list, repr=False)
|
|
65
|
+
|
|
66
|
+
def __post_init__(self) -> None:
|
|
67
|
+
if self.queue_dir:
|
|
68
|
+
self.queue_dir = Path(self.queue_dir)
|
|
69
|
+
self.queue_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
self._load_queue()
|
|
71
|
+
|
|
72
|
+
def _load_queue(self) -> None:
|
|
73
|
+
"""Load queued events from disk."""
|
|
74
|
+
if not self.queue_dir:
|
|
75
|
+
return
|
|
76
|
+
queue_file = self.queue_dir / "event_queue.json"
|
|
77
|
+
if queue_file.exists():
|
|
78
|
+
try:
|
|
79
|
+
self._queue = json.loads(queue_file.read_text())
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
self._queue = []
|
|
82
|
+
|
|
83
|
+
def _save_queue(self) -> None:
|
|
84
|
+
"""Save queued events to disk."""
|
|
85
|
+
if not self.queue_dir:
|
|
86
|
+
return
|
|
87
|
+
queue_file = self.queue_dir / "event_queue.json"
|
|
88
|
+
queue_file.write_text(json.dumps(self._queue))
|
|
89
|
+
|
|
90
|
+
def _queue_event(self, event: EventPayload) -> None:
|
|
91
|
+
"""Add event to offline queue."""
|
|
92
|
+
self._queue.append(event.model_dump())
|
|
93
|
+
self._save_queue()
|
|
94
|
+
|
|
95
|
+
async def push_event(self, event: EventPayload) -> bool:
|
|
96
|
+
"""Push event to dashboard, queue on failure."""
|
|
97
|
+
payload = event.model_dump()
|
|
98
|
+
payload["node_id"] = self.node_id
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
102
|
+
response = await client.post(
|
|
103
|
+
f"{self.url}/event",
|
|
104
|
+
json=payload,
|
|
105
|
+
headers={"Authorization": f"Bearer {self.auth_token}"},
|
|
106
|
+
)
|
|
107
|
+
response.raise_for_status()
|
|
108
|
+
return True
|
|
109
|
+
except (httpx.HTTPError, httpx.TimeoutException):
|
|
110
|
+
self._queue_event(event)
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
async def heartbeat(self, payload: HeartbeatPayload) -> bool:
|
|
114
|
+
"""Send heartbeat to dashboard."""
|
|
115
|
+
data = payload.model_dump()
|
|
116
|
+
data["node_id"] = self.node_id
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
120
|
+
response = await client.post(
|
|
121
|
+
f"{self.url}/heartbeat",
|
|
122
|
+
json=data,
|
|
123
|
+
headers={"Authorization": f"Bearer {self.auth_token}"},
|
|
124
|
+
)
|
|
125
|
+
response.raise_for_status()
|
|
126
|
+
return True
|
|
127
|
+
except (httpx.HTTPError, httpx.TimeoutException):
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
async def drain_queue(self) -> int:
|
|
131
|
+
"""Attempt to send queued events. Returns count of successfully sent."""
|
|
132
|
+
if not self._queue:
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
sent = 0
|
|
136
|
+
remaining = []
|
|
137
|
+
|
|
138
|
+
for event_data in self._queue:
|
|
139
|
+
try:
|
|
140
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
141
|
+
event_data["node_id"] = self.node_id
|
|
142
|
+
response = await client.post(
|
|
143
|
+
f"{self.url}/event",
|
|
144
|
+
json=event_data,
|
|
145
|
+
headers={"Authorization": f"Bearer {self.auth_token}"},
|
|
146
|
+
)
|
|
147
|
+
response.raise_for_status()
|
|
148
|
+
sent += 1
|
|
149
|
+
except (httpx.HTTPError, httpx.TimeoutException):
|
|
150
|
+
remaining.append(event_data)
|
|
151
|
+
|
|
152
|
+
self._queue = remaining
|
|
153
|
+
self._save_queue()
|
|
154
|
+
return sent
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def queue_size(self) -> int:
|
|
158
|
+
"""Number of events in offline queue."""
|
|
159
|
+
return len(self._queue)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Pipeline implementations for ctrlrelay."""
|
|
2
|
+
|
|
3
|
+
from ctrlrelay.pipelines.base import Pipeline, PipelineContext, PipelineResult
|
|
4
|
+
from ctrlrelay.pipelines.dev import DevPipeline, run_dev_issue
|
|
5
|
+
from ctrlrelay.pipelines.secops import SecopsPipeline, run_secops_all
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Pipeline",
|
|
9
|
+
"PipelineContext",
|
|
10
|
+
"PipelineResult",
|
|
11
|
+
"SecopsPipeline",
|
|
12
|
+
"run_secops_all",
|
|
13
|
+
"DevPipeline",
|
|
14
|
+
"run_dev_issue",
|
|
15
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Base protocol and types for pipelines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class PipelineContext:
|
|
12
|
+
"""Context for a pipeline execution."""
|
|
13
|
+
|
|
14
|
+
session_id: str
|
|
15
|
+
repo: str
|
|
16
|
+
worktree_path: Path
|
|
17
|
+
context_path: Path
|
|
18
|
+
state_file: Path
|
|
19
|
+
issue_number: int | None = None
|
|
20
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class PipelineResult:
|
|
25
|
+
"""Result of a pipeline execution."""
|
|
26
|
+
|
|
27
|
+
success: bool
|
|
28
|
+
session_id: str
|
|
29
|
+
summary: str
|
|
30
|
+
blocked: bool = False
|
|
31
|
+
question: str | None = None
|
|
32
|
+
error: str | None = None
|
|
33
|
+
outputs: dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@runtime_checkable
|
|
37
|
+
class Pipeline(Protocol):
|
|
38
|
+
"""Protocol for pipeline implementations."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
|
|
42
|
+
async def run(self, ctx: PipelineContext) -> PipelineResult:
|
|
43
|
+
"""Execute the pipeline."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
async def resume(
|
|
47
|
+
self, ctx: PipelineContext, answer: str
|
|
48
|
+
) -> PipelineResult:
|
|
49
|
+
"""Resume a blocked pipeline with user answer."""
|
|
50
|
+
...
|