codex-autorunner 0.1.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.
- codex_autorunner/__init__.py +3 -0
- codex_autorunner/bootstrap.py +151 -0
- codex_autorunner/cli.py +886 -0
- codex_autorunner/codex_cli.py +79 -0
- codex_autorunner/codex_runner.py +17 -0
- codex_autorunner/core/__init__.py +1 -0
- codex_autorunner/core/about_car.py +125 -0
- codex_autorunner/core/codex_runner.py +100 -0
- codex_autorunner/core/config.py +1465 -0
- codex_autorunner/core/doc_chat.py +547 -0
- codex_autorunner/core/docs.py +37 -0
- codex_autorunner/core/engine.py +720 -0
- codex_autorunner/core/git_utils.py +206 -0
- codex_autorunner/core/hub.py +756 -0
- codex_autorunner/core/injected_context.py +9 -0
- codex_autorunner/core/locks.py +57 -0
- codex_autorunner/core/logging_utils.py +158 -0
- codex_autorunner/core/notifications.py +465 -0
- codex_autorunner/core/optional_dependencies.py +41 -0
- codex_autorunner/core/prompt.py +107 -0
- codex_autorunner/core/prompts.py +275 -0
- codex_autorunner/core/request_context.py +21 -0
- codex_autorunner/core/runner_controller.py +116 -0
- codex_autorunner/core/runner_process.py +29 -0
- codex_autorunner/core/snapshot.py +576 -0
- codex_autorunner/core/state.py +156 -0
- codex_autorunner/core/update.py +567 -0
- codex_autorunner/core/update_runner.py +44 -0
- codex_autorunner/core/usage.py +1221 -0
- codex_autorunner/core/utils.py +108 -0
- codex_autorunner/discovery.py +102 -0
- codex_autorunner/housekeeping.py +423 -0
- codex_autorunner/integrations/__init__.py +1 -0
- codex_autorunner/integrations/app_server/__init__.py +6 -0
- codex_autorunner/integrations/app_server/client.py +1386 -0
- codex_autorunner/integrations/app_server/supervisor.py +206 -0
- codex_autorunner/integrations/github/__init__.py +10 -0
- codex_autorunner/integrations/github/service.py +889 -0
- codex_autorunner/integrations/telegram/__init__.py +1 -0
- codex_autorunner/integrations/telegram/adapter.py +1401 -0
- codex_autorunner/integrations/telegram/commands_registry.py +104 -0
- codex_autorunner/integrations/telegram/config.py +450 -0
- codex_autorunner/integrations/telegram/constants.py +154 -0
- codex_autorunner/integrations/telegram/dispatch.py +162 -0
- codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
- codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
- codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
- codex_autorunner/integrations/telegram/helpers.py +2084 -0
- codex_autorunner/integrations/telegram/notifications.py +164 -0
- codex_autorunner/integrations/telegram/outbox.py +174 -0
- codex_autorunner/integrations/telegram/rendering.py +102 -0
- codex_autorunner/integrations/telegram/retry.py +37 -0
- codex_autorunner/integrations/telegram/runtime.py +270 -0
- codex_autorunner/integrations/telegram/service.py +921 -0
- codex_autorunner/integrations/telegram/state.py +1223 -0
- codex_autorunner/integrations/telegram/transport.py +318 -0
- codex_autorunner/integrations/telegram/types.py +57 -0
- codex_autorunner/integrations/telegram/voice.py +413 -0
- codex_autorunner/manifest.py +150 -0
- codex_autorunner/routes/__init__.py +53 -0
- codex_autorunner/routes/base.py +470 -0
- codex_autorunner/routes/docs.py +275 -0
- codex_autorunner/routes/github.py +197 -0
- codex_autorunner/routes/repos.py +121 -0
- codex_autorunner/routes/sessions.py +137 -0
- codex_autorunner/routes/shared.py +137 -0
- codex_autorunner/routes/system.py +175 -0
- codex_autorunner/routes/terminal_images.py +107 -0
- codex_autorunner/routes/voice.py +128 -0
- codex_autorunner/server.py +23 -0
- codex_autorunner/spec_ingest.py +113 -0
- codex_autorunner/static/app.js +95 -0
- codex_autorunner/static/autoRefresh.js +209 -0
- codex_autorunner/static/bootstrap.js +105 -0
- codex_autorunner/static/bus.js +23 -0
- codex_autorunner/static/cache.js +52 -0
- codex_autorunner/static/constants.js +48 -0
- codex_autorunner/static/dashboard.js +795 -0
- codex_autorunner/static/docs.js +1514 -0
- codex_autorunner/static/env.js +99 -0
- codex_autorunner/static/github.js +168 -0
- codex_autorunner/static/hub.js +1511 -0
- codex_autorunner/static/index.html +622 -0
- codex_autorunner/static/loader.js +28 -0
- codex_autorunner/static/logs.js +690 -0
- codex_autorunner/static/mobileCompact.js +300 -0
- codex_autorunner/static/snapshot.js +116 -0
- codex_autorunner/static/state.js +87 -0
- codex_autorunner/static/styles.css +4966 -0
- codex_autorunner/static/tabs.js +50 -0
- codex_autorunner/static/terminal.js +21 -0
- codex_autorunner/static/terminalManager.js +3535 -0
- codex_autorunner/static/todoPreview.js +25 -0
- codex_autorunner/static/types.d.ts +8 -0
- codex_autorunner/static/utils.js +597 -0
- codex_autorunner/static/vendor/LICENSE.xterm +24 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
- codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
- codex_autorunner/static/vendor/xterm.css +209 -0
- codex_autorunner/static/vendor/xterm.js +2 -0
- codex_autorunner/static/voice.js +591 -0
- codex_autorunner/voice/__init__.py +39 -0
- codex_autorunner/voice/capture.py +349 -0
- codex_autorunner/voice/config.py +167 -0
- codex_autorunner/voice/provider.py +66 -0
- codex_autorunner/voice/providers/__init__.py +7 -0
- codex_autorunner/voice/providers/openai_whisper.py +345 -0
- codex_autorunner/voice/resolver.py +36 -0
- codex_autorunner/voice/service.py +210 -0
- codex_autorunner/web/__init__.py +1 -0
- codex_autorunner/web/app.py +1037 -0
- codex_autorunner/web/hub_jobs.py +181 -0
- codex_autorunner/web/middleware.py +552 -0
- codex_autorunner/web/pty_session.py +357 -0
- codex_autorunner/web/runner_manager.py +25 -0
- codex_autorunner/web/schemas.py +253 -0
- codex_autorunner/web/static_assets.py +430 -0
- codex_autorunner/web/terminal_sessions.py +78 -0
- codex_autorunner/workspace.py +16 -0
- codex_autorunner-0.1.0.dist-info/METADATA +240 -0
- codex_autorunner-0.1.0.dist-info/RECORD +147 -0
- codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
- codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
- codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
- codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
INJECTED_CONTEXT_START = "<injected context>"
|
|
4
|
+
INJECTED_CONTEXT_END = "</injected context>"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def wrap_injected_context(text: str) -> str:
|
|
8
|
+
"""Wrap prompt hints in injected context blocks."""
|
|
9
|
+
return f"{INJECTED_CONTEXT_START}\n{text}\n{INJECTED_CONTEXT_END}"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import socket
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .utils import atomic_write
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class LockInfo:
|
|
13
|
+
pid: Optional[int]
|
|
14
|
+
started_at: Optional[str]
|
|
15
|
+
host: Optional[str]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def process_alive(pid: int) -> bool:
|
|
19
|
+
try:
|
|
20
|
+
os.kill(pid, 0)
|
|
21
|
+
except OSError:
|
|
22
|
+
return False
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_lock_info(lock_path: Path) -> LockInfo:
|
|
27
|
+
if not lock_path.exists():
|
|
28
|
+
return LockInfo(pid=None, started_at=None, host=None)
|
|
29
|
+
try:
|
|
30
|
+
text = lock_path.read_text(encoding="utf-8").strip()
|
|
31
|
+
except OSError:
|
|
32
|
+
return LockInfo(pid=None, started_at=None, host=None)
|
|
33
|
+
if not text:
|
|
34
|
+
return LockInfo(pid=None, started_at=None, host=None)
|
|
35
|
+
if text.startswith("{"):
|
|
36
|
+
try:
|
|
37
|
+
payload = json.loads(text)
|
|
38
|
+
pid = payload.get("pid")
|
|
39
|
+
return LockInfo(
|
|
40
|
+
pid=int(pid) if isinstance(pid, int) or str(pid).isdigit() else None,
|
|
41
|
+
started_at=payload.get("started_at"),
|
|
42
|
+
host=payload.get("host"),
|
|
43
|
+
)
|
|
44
|
+
except Exception:
|
|
45
|
+
return LockInfo(pid=None, started_at=None, host=None)
|
|
46
|
+
pid = int(text) if text.isdigit() else None
|
|
47
|
+
return LockInfo(pid=pid, started_at=None, host=None)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write_lock_info(lock_path: Path, pid: int, *, started_at: str) -> None:
|
|
51
|
+
payload = {
|
|
52
|
+
"pid": pid,
|
|
53
|
+
"started_at": started_at,
|
|
54
|
+
"host": socket.gethostname(),
|
|
55
|
+
}
|
|
56
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
atomic_write(lock_path, json.dumps(payload) + "\n")
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import collections
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from logging.handlers import RotatingFileHandler
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Mapping, Optional, OrderedDict
|
|
7
|
+
|
|
8
|
+
from .config import LogConfig
|
|
9
|
+
from .request_context import get_request_id
|
|
10
|
+
|
|
11
|
+
_MAX_CACHED_LOGGERS = 64
|
|
12
|
+
_LOGGER_CACHE: "OrderedDict[str, logging.Logger]" = collections.OrderedDict()
|
|
13
|
+
_REDACTED_VALUE = "<redacted>"
|
|
14
|
+
_SENSITIVE_FIELD_PARTS = (
|
|
15
|
+
"api_key",
|
|
16
|
+
"apikey",
|
|
17
|
+
"authorization",
|
|
18
|
+
"bot_token",
|
|
19
|
+
"openai_api_key",
|
|
20
|
+
"password",
|
|
21
|
+
"secret",
|
|
22
|
+
"token",
|
|
23
|
+
)
|
|
24
|
+
_MAX_LOG_STRING = 200
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def setup_rotating_logger(name: str, log_config: LogConfig) -> logging.Logger:
|
|
28
|
+
"""
|
|
29
|
+
Configure (or retrieve) an isolated rotating logger for the given name.
|
|
30
|
+
Each logger owns a single handler to avoid shared handlers across hub/repos.
|
|
31
|
+
"""
|
|
32
|
+
existing = _LOGGER_CACHE.get(name)
|
|
33
|
+
if existing is not None:
|
|
34
|
+
# Keep cache bounded and prefer most-recently-used.
|
|
35
|
+
_LOGGER_CACHE.move_to_end(name)
|
|
36
|
+
return existing
|
|
37
|
+
|
|
38
|
+
log_path: Path = log_config.path
|
|
39
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
handler = RotatingFileHandler(
|
|
41
|
+
log_path,
|
|
42
|
+
maxBytes=log_config.max_bytes,
|
|
43
|
+
backupCount=log_config.backup_count,
|
|
44
|
+
encoding="utf-8",
|
|
45
|
+
)
|
|
46
|
+
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(name)
|
|
49
|
+
logger.handlers.clear()
|
|
50
|
+
logger.setLevel(logging.INFO)
|
|
51
|
+
logger.addHandler(handler)
|
|
52
|
+
logger.propagate = False
|
|
53
|
+
|
|
54
|
+
_LOGGER_CACHE[name] = logger
|
|
55
|
+
_LOGGER_CACHE.move_to_end(name)
|
|
56
|
+
# Bounded cache to avoid unbounded growth in long-lived hub processes.
|
|
57
|
+
while len(_LOGGER_CACHE) > _MAX_CACHED_LOGGERS:
|
|
58
|
+
_, evicted = _LOGGER_CACHE.popitem(last=False)
|
|
59
|
+
try:
|
|
60
|
+
for h in list(evicted.handlers):
|
|
61
|
+
try:
|
|
62
|
+
h.close()
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
evicted.handlers.clear()
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
return logger
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def safe_log(
|
|
72
|
+
logger: logging.Logger,
|
|
73
|
+
level: int,
|
|
74
|
+
message: str,
|
|
75
|
+
*args,
|
|
76
|
+
exc: Optional[Exception] = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
try:
|
|
79
|
+
formatted = message
|
|
80
|
+
if args:
|
|
81
|
+
try:
|
|
82
|
+
formatted = message % args
|
|
83
|
+
except Exception:
|
|
84
|
+
formatted = f"{message} {' '.join(str(arg) for arg in args)}"
|
|
85
|
+
if exc is not None:
|
|
86
|
+
formatted = f"{formatted}: {exc}"
|
|
87
|
+
logger.log(level, formatted)
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def log_event(
|
|
93
|
+
logger: logging.Logger,
|
|
94
|
+
level: int,
|
|
95
|
+
event: str,
|
|
96
|
+
*,
|
|
97
|
+
exc: Optional[Exception] = None,
|
|
98
|
+
**fields: Any,
|
|
99
|
+
) -> None:
|
|
100
|
+
payload: dict[str, Any] = {"event": event}
|
|
101
|
+
if "request_id" not in fields:
|
|
102
|
+
request_id = get_request_id()
|
|
103
|
+
if request_id:
|
|
104
|
+
fields["request_id"] = request_id
|
|
105
|
+
if fields:
|
|
106
|
+
payload.update(_sanitize_fields(fields))
|
|
107
|
+
if exc is not None:
|
|
108
|
+
payload["error"] = _sanitize_value(str(exc))
|
|
109
|
+
payload["error_type"] = type(exc).__name__
|
|
110
|
+
try:
|
|
111
|
+
message = json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
|
|
112
|
+
logger.log(level, message)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def sanitize_log_value(value: Any) -> Any:
|
|
118
|
+
"""Expose the standard log sanitization for ad-hoc values."""
|
|
119
|
+
return _sanitize_value(value)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _sanitize_fields(fields: Mapping[str, Any]) -> dict[str, Any]:
|
|
123
|
+
sanitized: dict[str, Any] = {}
|
|
124
|
+
for key, value in fields.items():
|
|
125
|
+
if _is_sensitive_key(str(key)):
|
|
126
|
+
sanitized[key] = _REDACTED_VALUE
|
|
127
|
+
else:
|
|
128
|
+
sanitized[key] = _sanitize_value(value)
|
|
129
|
+
return sanitized
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _sanitize_value(value: Any) -> Any:
|
|
133
|
+
if isinstance(value, Mapping):
|
|
134
|
+
return _sanitize_mapping(value)
|
|
135
|
+
if isinstance(value, (list, tuple, set)):
|
|
136
|
+
return [_sanitize_value(item) for item in value]
|
|
137
|
+
if isinstance(value, str):
|
|
138
|
+
if len(value) > _MAX_LOG_STRING:
|
|
139
|
+
return value[: _MAX_LOG_STRING - 3] + "..."
|
|
140
|
+
return value
|
|
141
|
+
if isinstance(value, (int, float, bool)) or value is None:
|
|
142
|
+
return value
|
|
143
|
+
return str(value)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _sanitize_mapping(mapping: Mapping[str, Any]) -> dict[str, Any]:
|
|
147
|
+
sanitized: dict[str, Any] = {}
|
|
148
|
+
for key, value in mapping.items():
|
|
149
|
+
if _is_sensitive_key(str(key)):
|
|
150
|
+
sanitized[key] = _REDACTED_VALUE
|
|
151
|
+
else:
|
|
152
|
+
sanitized[key] = _sanitize_value(value)
|
|
153
|
+
return sanitized
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _is_sensitive_key(key: str) -> bool:
|
|
157
|
+
lowered = key.lower()
|
|
158
|
+
return any(part in lowered for part in _SENSITIVE_FIELD_PARTS)
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .config import Config
|
|
11
|
+
|
|
12
|
+
DEFAULT_EVENTS = {"run_finished", "run_error", "tui_idle"}
|
|
13
|
+
KNOWN_EVENTS = {"run_finished", "run_error", "tui_idle", "tui_session_finished", "all"}
|
|
14
|
+
DEFAULT_TIMEOUT_SECONDS = 5.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NotificationManager:
|
|
18
|
+
def __init__(self, config: Config, *, logger: Optional[logging.Logger] = None):
|
|
19
|
+
self.config = config
|
|
20
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
21
|
+
raw = config.raw.get("notifications")
|
|
22
|
+
self._cfg: Dict[str, Any] = raw if isinstance(raw, dict) else {}
|
|
23
|
+
self._warned_missing: set[str] = set()
|
|
24
|
+
self._enabled_mode = self._parse_enabled(self._cfg.get("enabled"))
|
|
25
|
+
self._events = self._normalize_events(self._cfg.get("events"))
|
|
26
|
+
self._warn_unknown_events(self._events)
|
|
27
|
+
discord_cfg = self._cfg.get("discord")
|
|
28
|
+
self._discord: Dict[str, Any] = (
|
|
29
|
+
discord_cfg if isinstance(discord_cfg, dict) else {}
|
|
30
|
+
)
|
|
31
|
+
telegram_cfg = self._cfg.get("telegram")
|
|
32
|
+
self._telegram: Dict[str, Any] = (
|
|
33
|
+
telegram_cfg if isinstance(telegram_cfg, dict) else {}
|
|
34
|
+
)
|
|
35
|
+
self._discord_enabled = self._discord.get("enabled") is not False
|
|
36
|
+
self._telegram_enabled = self._telegram.get("enabled") is not False
|
|
37
|
+
|
|
38
|
+
def set_logger(self, logger: logging.Logger) -> None:
|
|
39
|
+
self.logger = logger
|
|
40
|
+
|
|
41
|
+
def notify_run_finished(self, *, run_id: int, exit_code: Optional[int]) -> None:
|
|
42
|
+
event = "run_finished" if exit_code == 0 else "run_error"
|
|
43
|
+
message = self._format_run_message(run_id=run_id, exit_code=exit_code)
|
|
44
|
+
self._notify_sync(event, message, repo_path=str(self.config.root))
|
|
45
|
+
|
|
46
|
+
async def notify_run_finished_async(
|
|
47
|
+
self, *, run_id: int, exit_code: Optional[int]
|
|
48
|
+
) -> None:
|
|
49
|
+
event = "run_finished" if exit_code == 0 else "run_error"
|
|
50
|
+
message = self._format_run_message(run_id=run_id, exit_code=exit_code)
|
|
51
|
+
await self._notify_async(event, message, repo_path=str(self.config.root))
|
|
52
|
+
|
|
53
|
+
def notify_tui_session_finished(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
session_id: Optional[str],
|
|
57
|
+
exit_code: Optional[int],
|
|
58
|
+
repo_path: Optional[str] = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
message = self._format_tui_message(
|
|
61
|
+
session_id=session_id, exit_code=exit_code, repo_path=repo_path
|
|
62
|
+
)
|
|
63
|
+
self._notify_sync("tui_session_finished", message, repo_path=repo_path)
|
|
64
|
+
|
|
65
|
+
async def notify_tui_session_finished_async(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
session_id: Optional[str],
|
|
69
|
+
exit_code: Optional[int],
|
|
70
|
+
repo_path: Optional[str] = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
message = self._format_tui_message(
|
|
73
|
+
session_id=session_id, exit_code=exit_code, repo_path=repo_path
|
|
74
|
+
)
|
|
75
|
+
await self._notify_async("tui_session_finished", message, repo_path=repo_path)
|
|
76
|
+
|
|
77
|
+
def notify_tui_idle(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
session_id: Optional[str],
|
|
81
|
+
idle_seconds: float,
|
|
82
|
+
repo_path: Optional[str] = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
message = self._format_tui_idle_message(
|
|
85
|
+
session_id=session_id,
|
|
86
|
+
idle_seconds=idle_seconds,
|
|
87
|
+
repo_path=repo_path,
|
|
88
|
+
)
|
|
89
|
+
self._notify_sync("tui_idle", message, repo_path=repo_path)
|
|
90
|
+
|
|
91
|
+
async def notify_tui_idle_async(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
session_id: Optional[str],
|
|
95
|
+
idle_seconds: float,
|
|
96
|
+
repo_path: Optional[str] = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
message = self._format_tui_idle_message(
|
|
99
|
+
session_id=session_id,
|
|
100
|
+
idle_seconds=idle_seconds,
|
|
101
|
+
repo_path=repo_path,
|
|
102
|
+
)
|
|
103
|
+
await self._notify_async("tui_idle", message, repo_path=repo_path)
|
|
104
|
+
|
|
105
|
+
def _normalize_events(self, raw_events) -> set[str]:
|
|
106
|
+
if raw_events is None:
|
|
107
|
+
return set(DEFAULT_EVENTS)
|
|
108
|
+
if not isinstance(raw_events, list):
|
|
109
|
+
return set(DEFAULT_EVENTS)
|
|
110
|
+
normalized = {
|
|
111
|
+
item.strip()
|
|
112
|
+
for item in raw_events
|
|
113
|
+
if isinstance(item, str) and item.strip()
|
|
114
|
+
}
|
|
115
|
+
return normalized
|
|
116
|
+
|
|
117
|
+
def _warn_unknown_events(self, events: set[str]) -> None:
|
|
118
|
+
unknown = {event for event in events if event not in KNOWN_EVENTS}
|
|
119
|
+
if not unknown:
|
|
120
|
+
return
|
|
121
|
+
details = ", ".join(sorted(unknown))
|
|
122
|
+
self._warn_once(
|
|
123
|
+
"notifications.unknown_events",
|
|
124
|
+
f"Unknown notification events configured: {details}",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _should_notify(self, event: str) -> bool:
|
|
128
|
+
enabled = self._is_enabled()
|
|
129
|
+
if not enabled:
|
|
130
|
+
return False
|
|
131
|
+
if not self._events:
|
|
132
|
+
return False
|
|
133
|
+
if "all" in self._events:
|
|
134
|
+
return True
|
|
135
|
+
return event in self._events
|
|
136
|
+
|
|
137
|
+
def _parse_enabled(self, raw) -> bool | str:
|
|
138
|
+
if isinstance(raw, bool):
|
|
139
|
+
return raw
|
|
140
|
+
if raw is None:
|
|
141
|
+
return "auto"
|
|
142
|
+
if isinstance(raw, str) and raw.strip().lower() == "auto":
|
|
143
|
+
return "auto"
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
def _is_enabled(self) -> bool:
|
|
147
|
+
if self._enabled_mode is True:
|
|
148
|
+
return True
|
|
149
|
+
if self._enabled_mode is False:
|
|
150
|
+
return False
|
|
151
|
+
return self._targets_available()
|
|
152
|
+
|
|
153
|
+
def _format_run_message(self, *, run_id: int, exit_code: Optional[int]) -> str:
|
|
154
|
+
repo_label = self._repo_label()
|
|
155
|
+
if exit_code == 0:
|
|
156
|
+
status = "complete"
|
|
157
|
+
summary_text = "summary finalized"
|
|
158
|
+
else:
|
|
159
|
+
status = "failed"
|
|
160
|
+
summary_text = None
|
|
161
|
+
code_text = f"exit {exit_code}" if exit_code is not None else "exit unknown"
|
|
162
|
+
if summary_text:
|
|
163
|
+
details = f"{summary_text}, {code_text}"
|
|
164
|
+
else:
|
|
165
|
+
details = code_text
|
|
166
|
+
return f"CAR run {run_id} {status} ({details}) in {repo_label}"
|
|
167
|
+
|
|
168
|
+
def _format_tui_message(
|
|
169
|
+
self,
|
|
170
|
+
*,
|
|
171
|
+
session_id: Optional[str],
|
|
172
|
+
exit_code: Optional[int],
|
|
173
|
+
repo_path: Optional[str],
|
|
174
|
+
) -> str:
|
|
175
|
+
repo_label = repo_path or self._repo_label()
|
|
176
|
+
session_text = f"session {session_id}" if session_id else "session"
|
|
177
|
+
code_text = f"exit {exit_code}" if exit_code is not None else "exit unknown"
|
|
178
|
+
return f"CAR TUI session ended ({session_text}, {code_text}) in {repo_label}"
|
|
179
|
+
|
|
180
|
+
def _format_tui_idle_message(
|
|
181
|
+
self,
|
|
182
|
+
*,
|
|
183
|
+
session_id: Optional[str],
|
|
184
|
+
idle_seconds: float,
|
|
185
|
+
repo_path: Optional[str],
|
|
186
|
+
) -> str:
|
|
187
|
+
repo_label = repo_path or self._repo_label()
|
|
188
|
+
session_text = f"session {session_id}" if session_id else "session"
|
|
189
|
+
idle_text = f"idle {int(idle_seconds)}s"
|
|
190
|
+
return f"CAR TUI idle ({session_text}, {idle_text}) in {repo_label}"
|
|
191
|
+
|
|
192
|
+
def _repo_label(self) -> str:
|
|
193
|
+
name = self.config.root.name
|
|
194
|
+
return name or str(self.config.root)
|
|
195
|
+
|
|
196
|
+
def _notify_sync(
|
|
197
|
+
self, event: str, message: str, *, repo_path: Optional[str] = None
|
|
198
|
+
) -> None:
|
|
199
|
+
if not self._should_notify(event):
|
|
200
|
+
return
|
|
201
|
+
targets = self._resolve_targets(repo_path=repo_path)
|
|
202
|
+
if not targets:
|
|
203
|
+
return
|
|
204
|
+
try:
|
|
205
|
+
with httpx.Client(timeout=DEFAULT_TIMEOUT_SECONDS) as client:
|
|
206
|
+
self._send_sync(client, targets, message)
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
self._log_warning("Notification delivery failed", exc)
|
|
209
|
+
|
|
210
|
+
async def _notify_async(
|
|
211
|
+
self, event: str, message: str, *, repo_path: Optional[str] = None
|
|
212
|
+
) -> None:
|
|
213
|
+
if not self._should_notify(event):
|
|
214
|
+
return
|
|
215
|
+
targets = self._resolve_targets(repo_path=repo_path)
|
|
216
|
+
if not targets:
|
|
217
|
+
return
|
|
218
|
+
try:
|
|
219
|
+
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT_SECONDS) as client:
|
|
220
|
+
await self._send_async(client, targets, message)
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
self._log_warning("Notification delivery failed", exc)
|
|
223
|
+
|
|
224
|
+
def _resolve_targets(
|
|
225
|
+
self, *, repo_path: Optional[str] = None
|
|
226
|
+
) -> dict[str, dict[str, object]]:
|
|
227
|
+
targets: dict[str, dict[str, object]] = {}
|
|
228
|
+
discord_url = self._resolve_discord_webhook()
|
|
229
|
+
if discord_url:
|
|
230
|
+
targets["discord"] = {"webhook_url": discord_url}
|
|
231
|
+
telegram = self._resolve_telegram(repo_path=repo_path)
|
|
232
|
+
if telegram:
|
|
233
|
+
targets["telegram"] = telegram
|
|
234
|
+
if not targets:
|
|
235
|
+
self._warn_once(
|
|
236
|
+
"notifications.none_configured",
|
|
237
|
+
"Notifications enabled but no targets configured",
|
|
238
|
+
)
|
|
239
|
+
return targets
|
|
240
|
+
|
|
241
|
+
def _targets_available(self) -> bool:
|
|
242
|
+
if self._discord_enabled and self._peek_discord_webhook():
|
|
243
|
+
return True
|
|
244
|
+
if self._telegram_enabled and self._peek_telegram():
|
|
245
|
+
return True
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
def _peek_discord_webhook(self) -> bool:
|
|
249
|
+
env_key = self._discord.get("webhook_url_env")
|
|
250
|
+
if not env_key or not isinstance(env_key, str):
|
|
251
|
+
return False
|
|
252
|
+
return bool(os.environ.get(env_key))
|
|
253
|
+
|
|
254
|
+
def _peek_telegram(self) -> bool:
|
|
255
|
+
token_key = self._telegram.get("bot_token_env")
|
|
256
|
+
chat_id_key = self._telegram.get("chat_id_env")
|
|
257
|
+
if not token_key or not chat_id_key:
|
|
258
|
+
return False
|
|
259
|
+
if not isinstance(token_key, str) or not isinstance(chat_id_key, str):
|
|
260
|
+
return False
|
|
261
|
+
return bool(os.environ.get(token_key) and os.environ.get(chat_id_key))
|
|
262
|
+
|
|
263
|
+
def _resolve_discord_webhook(self) -> Optional[str]:
|
|
264
|
+
if not self._discord_enabled:
|
|
265
|
+
return None
|
|
266
|
+
env_key = self._discord.get("webhook_url_env")
|
|
267
|
+
if env_key and isinstance(env_key, str):
|
|
268
|
+
value = os.environ.get(env_key)
|
|
269
|
+
if value:
|
|
270
|
+
return value
|
|
271
|
+
if self._discord.get("enabled") is True:
|
|
272
|
+
self._warn_once(
|
|
273
|
+
"discord.webhook_url_env.missing",
|
|
274
|
+
f"Discord webhook env var missing: {env_key}",
|
|
275
|
+
)
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
def _resolve_telegram(
|
|
279
|
+
self, *, repo_path: Optional[str] = None
|
|
280
|
+
) -> Optional[dict[str, object]]:
|
|
281
|
+
if not self._telegram_enabled:
|
|
282
|
+
return None
|
|
283
|
+
token_key = self._telegram.get("bot_token_env")
|
|
284
|
+
chat_id_key = self._telegram.get("chat_id_env")
|
|
285
|
+
thread_id_key = self._telegram.get("thread_id_env")
|
|
286
|
+
token = os.environ.get(token_key) if isinstance(token_key, str) else None
|
|
287
|
+
chat_id = os.environ.get(chat_id_key) if isinstance(chat_id_key, str) else None
|
|
288
|
+
thread_id = self._resolve_thread_id(repo_path)
|
|
289
|
+
if thread_id is None:
|
|
290
|
+
thread_id = self._telegram.get("thread_id")
|
|
291
|
+
if not isinstance(thread_id, int):
|
|
292
|
+
thread_id = None
|
|
293
|
+
if thread_id is None:
|
|
294
|
+
thread_id_raw = (
|
|
295
|
+
os.environ.get(thread_id_key)
|
|
296
|
+
if isinstance(thread_id_key, str)
|
|
297
|
+
else None
|
|
298
|
+
)
|
|
299
|
+
if isinstance(thread_id_raw, str) and thread_id_raw.strip():
|
|
300
|
+
try:
|
|
301
|
+
thread_id = int(thread_id_raw.strip())
|
|
302
|
+
except ValueError:
|
|
303
|
+
thread_id = None
|
|
304
|
+
if token and chat_id:
|
|
305
|
+
payload: dict[str, object] = {"bot_token": token, "chat_id": chat_id}
|
|
306
|
+
if thread_id is not None:
|
|
307
|
+
payload["thread_id"] = thread_id
|
|
308
|
+
return payload
|
|
309
|
+
if self._telegram.get("enabled") is True:
|
|
310
|
+
if not token and token_key:
|
|
311
|
+
self._warn_once(
|
|
312
|
+
"telegram.bot_token_env.missing",
|
|
313
|
+
f"Telegram bot token env var missing: {token_key}",
|
|
314
|
+
)
|
|
315
|
+
if not chat_id and chat_id_key:
|
|
316
|
+
self._warn_once(
|
|
317
|
+
"telegram.chat_id_env.missing",
|
|
318
|
+
f"Telegram chat id env var missing: {chat_id_key}",
|
|
319
|
+
)
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
def _resolve_thread_id(self, repo_path: Optional[str]) -> Optional[int]:
|
|
323
|
+
if not repo_path or not isinstance(self._telegram, dict):
|
|
324
|
+
return None
|
|
325
|
+
thread_map = self._telegram.get("thread_id_map")
|
|
326
|
+
if not isinstance(thread_map, dict):
|
|
327
|
+
return None
|
|
328
|
+
repo_key = self._normalize_repo_path(repo_path)
|
|
329
|
+
if not repo_key:
|
|
330
|
+
return None
|
|
331
|
+
for key, value in thread_map.items():
|
|
332
|
+
if not isinstance(key, str) or not isinstance(value, int):
|
|
333
|
+
continue
|
|
334
|
+
map_key = self._normalize_repo_path(key)
|
|
335
|
+
if map_key and map_key == repo_key:
|
|
336
|
+
return value
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
def _normalize_repo_path(self, path: str) -> Optional[str]:
|
|
340
|
+
if not isinstance(path, str) or not path.strip():
|
|
341
|
+
return None
|
|
342
|
+
candidate = Path(path).expanduser()
|
|
343
|
+
if not candidate.is_absolute():
|
|
344
|
+
candidate = (self.config.root / candidate).expanduser()
|
|
345
|
+
try:
|
|
346
|
+
return str(candidate.resolve())
|
|
347
|
+
except Exception:
|
|
348
|
+
return str(candidate.absolute())
|
|
349
|
+
|
|
350
|
+
def _send_sync(
|
|
351
|
+
self, client: httpx.Client, targets: dict[str, dict[str, object]], message: str
|
|
352
|
+
) -> None:
|
|
353
|
+
if "discord" in targets:
|
|
354
|
+
try:
|
|
355
|
+
webhook_url = targets["discord"].get("webhook_url")
|
|
356
|
+
if isinstance(webhook_url, str):
|
|
357
|
+
self._send_discord_sync(client, webhook_url, message)
|
|
358
|
+
except Exception as exc:
|
|
359
|
+
self._log_delivery_failure("discord", exc)
|
|
360
|
+
if "telegram" in targets:
|
|
361
|
+
telegram = targets["telegram"]
|
|
362
|
+
try:
|
|
363
|
+
bot_token = telegram.get("bot_token")
|
|
364
|
+
chat_id = telegram.get("chat_id")
|
|
365
|
+
thread_id = telegram.get("thread_id")
|
|
366
|
+
if isinstance(bot_token, str) and isinstance(chat_id, str):
|
|
367
|
+
self._send_telegram_sync(
|
|
368
|
+
client,
|
|
369
|
+
bot_token,
|
|
370
|
+
chat_id,
|
|
371
|
+
thread_id if isinstance(thread_id, int) else None,
|
|
372
|
+
message,
|
|
373
|
+
)
|
|
374
|
+
except Exception as exc:
|
|
375
|
+
self._log_delivery_failure("telegram", exc)
|
|
376
|
+
|
|
377
|
+
async def _send_async(
|
|
378
|
+
self,
|
|
379
|
+
client: httpx.AsyncClient,
|
|
380
|
+
targets: dict[str, dict[str, object]],
|
|
381
|
+
message: str,
|
|
382
|
+
) -> None:
|
|
383
|
+
if "discord" in targets:
|
|
384
|
+
try:
|
|
385
|
+
webhook_url = targets["discord"].get("webhook_url")
|
|
386
|
+
if isinstance(webhook_url, str):
|
|
387
|
+
await self._send_discord_async(client, webhook_url, message)
|
|
388
|
+
except Exception as exc:
|
|
389
|
+
self._log_delivery_failure("discord", exc)
|
|
390
|
+
if "telegram" in targets:
|
|
391
|
+
telegram = targets["telegram"]
|
|
392
|
+
try:
|
|
393
|
+
bot_token = telegram.get("bot_token")
|
|
394
|
+
chat_id = telegram.get("chat_id")
|
|
395
|
+
thread_id = telegram.get("thread_id")
|
|
396
|
+
if isinstance(bot_token, str) and isinstance(chat_id, str):
|
|
397
|
+
await self._send_telegram_async(
|
|
398
|
+
client,
|
|
399
|
+
bot_token,
|
|
400
|
+
chat_id,
|
|
401
|
+
thread_id if isinstance(thread_id, int) else None,
|
|
402
|
+
message,
|
|
403
|
+
)
|
|
404
|
+
except Exception as exc:
|
|
405
|
+
self._log_delivery_failure("telegram", exc)
|
|
406
|
+
|
|
407
|
+
def _send_discord_sync(
|
|
408
|
+
self, client: httpx.Client, webhook_url: str, message: str
|
|
409
|
+
) -> None:
|
|
410
|
+
response = client.post(webhook_url, json={"content": message})
|
|
411
|
+
response.raise_for_status()
|
|
412
|
+
|
|
413
|
+
async def _send_discord_async(
|
|
414
|
+
self, client: httpx.AsyncClient, webhook_url: str, message: str
|
|
415
|
+
) -> None:
|
|
416
|
+
response = await client.post(webhook_url, json={"content": message})
|
|
417
|
+
response.raise_for_status()
|
|
418
|
+
|
|
419
|
+
def _send_telegram_sync(
|
|
420
|
+
self,
|
|
421
|
+
client: httpx.Client,
|
|
422
|
+
bot_token: str,
|
|
423
|
+
chat_id: str,
|
|
424
|
+
thread_id: Optional[int],
|
|
425
|
+
message: str,
|
|
426
|
+
) -> None:
|
|
427
|
+
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
428
|
+
payload: dict[str, object] = {"chat_id": chat_id, "text": message}
|
|
429
|
+
if thread_id is not None:
|
|
430
|
+
payload["message_thread_id"] = thread_id
|
|
431
|
+
response = client.post(url, json=payload)
|
|
432
|
+
response.raise_for_status()
|
|
433
|
+
|
|
434
|
+
async def _send_telegram_async(
|
|
435
|
+
self,
|
|
436
|
+
client: httpx.AsyncClient,
|
|
437
|
+
bot_token: str,
|
|
438
|
+
chat_id: str,
|
|
439
|
+
thread_id: Optional[int],
|
|
440
|
+
message: str,
|
|
441
|
+
) -> None:
|
|
442
|
+
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
443
|
+
payload: dict[str, object] = {"chat_id": chat_id, "text": message}
|
|
444
|
+
if thread_id is not None:
|
|
445
|
+
payload["message_thread_id"] = thread_id
|
|
446
|
+
response = await client.post(url, json=payload)
|
|
447
|
+
response.raise_for_status()
|
|
448
|
+
|
|
449
|
+
def _warn_once(self, key: str, message: str) -> None:
|
|
450
|
+
if key in self._warned_missing:
|
|
451
|
+
return
|
|
452
|
+
self._warned_missing.add(key)
|
|
453
|
+
self._log_warning(message)
|
|
454
|
+
|
|
455
|
+
def _log_delivery_failure(self, target: str, exc: Exception) -> None:
|
|
456
|
+
self._log_warning(f"Notification delivery failed for {target}", exc)
|
|
457
|
+
|
|
458
|
+
def _log_warning(self, message: str, exc: Optional[Exception] = None) -> None:
|
|
459
|
+
try:
|
|
460
|
+
if exc is not None:
|
|
461
|
+
self.logger.warning("%s: %s", message, exc)
|
|
462
|
+
else:
|
|
463
|
+
self.logger.warning("%s", message)
|
|
464
|
+
except Exception:
|
|
465
|
+
pass
|