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.
@@ -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
+ ...