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,413 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import random
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Awaitable, Callable, Optional
|
|
11
|
+
|
|
12
|
+
from ...core.logging_utils import log_event
|
|
13
|
+
from ...core.state import now_iso
|
|
14
|
+
from ...voice import VoiceConfig, VoiceService, VoiceServiceError
|
|
15
|
+
from .config import TelegramBotConfig
|
|
16
|
+
from .constants import (
|
|
17
|
+
VOICE_MAX_ATTEMPTS,
|
|
18
|
+
VOICE_RETRY_AFTER_BUFFER_SECONDS,
|
|
19
|
+
VOICE_RETRY_INITIAL_SECONDS,
|
|
20
|
+
VOICE_RETRY_INTERVAL_SECONDS,
|
|
21
|
+
VOICE_RETRY_JITTER_RATIO,
|
|
22
|
+
VOICE_RETRY_MAX_SECONDS,
|
|
23
|
+
)
|
|
24
|
+
from .helpers import _format_future_time, _parse_iso_timestamp
|
|
25
|
+
from .retry import _extract_retry_after_seconds
|
|
26
|
+
from .state import PendingVoiceRecord, TelegramStateStore
|
|
27
|
+
|
|
28
|
+
SendMessageFn = Callable[..., Awaitable[None]]
|
|
29
|
+
EditMessageFn = Callable[..., Awaitable[bool]]
|
|
30
|
+
SendProgressMessageFn = Callable[[PendingVoiceRecord, str], Awaitable[Optional[int]]]
|
|
31
|
+
DeliverTranscriptFn = Callable[[PendingVoiceRecord, str], Awaitable[None]]
|
|
32
|
+
DownloadFileFn = Callable[[str], Awaitable[tuple[bytes, Optional[str], Optional[int]]]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TelegramVoiceManager:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
config: TelegramBotConfig,
|
|
39
|
+
store: TelegramStateStore,
|
|
40
|
+
*,
|
|
41
|
+
voice_config: Optional[VoiceConfig],
|
|
42
|
+
voice_service: Optional[VoiceService],
|
|
43
|
+
send_message: SendMessageFn,
|
|
44
|
+
edit_message_text: EditMessageFn,
|
|
45
|
+
send_progress_message: SendProgressMessageFn,
|
|
46
|
+
deliver_transcript: DeliverTranscriptFn,
|
|
47
|
+
download_file: DownloadFileFn,
|
|
48
|
+
logger: logging.Logger,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._config = config
|
|
51
|
+
self._store = store
|
|
52
|
+
self._voice_config = voice_config
|
|
53
|
+
self._voice_service = voice_service
|
|
54
|
+
self._send_message = send_message
|
|
55
|
+
self._edit_message_text = edit_message_text
|
|
56
|
+
self._send_progress_message = send_progress_message
|
|
57
|
+
self._deliver_transcript = deliver_transcript
|
|
58
|
+
self._download_file = download_file
|
|
59
|
+
self._logger = logger
|
|
60
|
+
self._inflight: set[str] = set()
|
|
61
|
+
self._lock: Optional[asyncio.Lock] = None
|
|
62
|
+
|
|
63
|
+
def start(self) -> None:
|
|
64
|
+
self._inflight = set()
|
|
65
|
+
self._lock = asyncio.Lock()
|
|
66
|
+
|
|
67
|
+
async def restore(self) -> None:
|
|
68
|
+
records = self._store.list_pending_voice()
|
|
69
|
+
if not records:
|
|
70
|
+
return
|
|
71
|
+
log_event(
|
|
72
|
+
self._logger,
|
|
73
|
+
logging.INFO,
|
|
74
|
+
"telegram.voice.restore",
|
|
75
|
+
count=len(records),
|
|
76
|
+
)
|
|
77
|
+
await self._flush(records)
|
|
78
|
+
|
|
79
|
+
async def run_loop(self) -> None:
|
|
80
|
+
while True:
|
|
81
|
+
await asyncio.sleep(VOICE_RETRY_INTERVAL_SECONDS)
|
|
82
|
+
try:
|
|
83
|
+
records = self._store.list_pending_voice()
|
|
84
|
+
if records:
|
|
85
|
+
await self._flush(records)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
log_event(
|
|
88
|
+
self._logger,
|
|
89
|
+
logging.WARNING,
|
|
90
|
+
"telegram.voice.flush_failed",
|
|
91
|
+
exc=exc,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async def attempt(self, record_id: str) -> bool:
|
|
95
|
+
record = self._store.get_pending_voice(record_id)
|
|
96
|
+
if record is None:
|
|
97
|
+
return False
|
|
98
|
+
if not self._ready_for_attempt(record):
|
|
99
|
+
return False
|
|
100
|
+
if not await self._mark_inflight(record.record_id):
|
|
101
|
+
return False
|
|
102
|
+
inflight_id = record.record_id
|
|
103
|
+
try:
|
|
104
|
+
current_record = self._store.get_pending_voice(record.record_id)
|
|
105
|
+
if current_record is None:
|
|
106
|
+
return False
|
|
107
|
+
if not self._ready_for_attempt(current_record):
|
|
108
|
+
return False
|
|
109
|
+
done = await self._process(current_record)
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
retry_after = _extract_retry_after_seconds(exc)
|
|
112
|
+
await self._record_failure(record, exc, retry_after=retry_after)
|
|
113
|
+
return False
|
|
114
|
+
finally:
|
|
115
|
+
await self._clear_inflight(inflight_id)
|
|
116
|
+
if done:
|
|
117
|
+
self._store.delete_pending_voice(record.record_id)
|
|
118
|
+
return done
|
|
119
|
+
|
|
120
|
+
async def _flush(self, records: list[PendingVoiceRecord]) -> None:
|
|
121
|
+
for record in records:
|
|
122
|
+
if record.attempts >= VOICE_MAX_ATTEMPTS:
|
|
123
|
+
await self._give_up(
|
|
124
|
+
record,
|
|
125
|
+
"Voice transcription failed after retries. Please resend.",
|
|
126
|
+
)
|
|
127
|
+
continue
|
|
128
|
+
await self.attempt(record.record_id)
|
|
129
|
+
|
|
130
|
+
async def _process(self, record: PendingVoiceRecord) -> bool:
|
|
131
|
+
if (
|
|
132
|
+
not self._voice_service
|
|
133
|
+
or not self._voice_config
|
|
134
|
+
or not self._voice_config.enabled
|
|
135
|
+
):
|
|
136
|
+
await self._send_message(
|
|
137
|
+
record.chat_id,
|
|
138
|
+
"Voice transcription is disabled.",
|
|
139
|
+
thread_id=record.thread_id,
|
|
140
|
+
reply_to=record.message_id,
|
|
141
|
+
)
|
|
142
|
+
self._remove_voice_file(record)
|
|
143
|
+
return True
|
|
144
|
+
max_bytes = self._config.media.max_voice_bytes
|
|
145
|
+
if record.file_size and record.file_size > max_bytes:
|
|
146
|
+
await self._send_message(
|
|
147
|
+
record.chat_id,
|
|
148
|
+
f"Voice note too large (max {max_bytes} bytes).",
|
|
149
|
+
thread_id=record.thread_id,
|
|
150
|
+
reply_to=record.message_id,
|
|
151
|
+
)
|
|
152
|
+
self._remove_voice_file(record)
|
|
153
|
+
return True
|
|
154
|
+
if record.transcript_text:
|
|
155
|
+
await self._deliver_transcript(record, record.transcript_text)
|
|
156
|
+
self._remove_voice_file(record)
|
|
157
|
+
return True
|
|
158
|
+
path = self._resolve_voice_download_path(record)
|
|
159
|
+
if path is None:
|
|
160
|
+
data, file_path, file_size = await self._download_file(record.file_id)
|
|
161
|
+
if file_size and file_size > max_bytes:
|
|
162
|
+
await self._send_message(
|
|
163
|
+
record.chat_id,
|
|
164
|
+
f"Voice note too large (max {max_bytes} bytes).",
|
|
165
|
+
thread_id=record.thread_id,
|
|
166
|
+
reply_to=record.message_id,
|
|
167
|
+
)
|
|
168
|
+
return True
|
|
169
|
+
if len(data) > max_bytes:
|
|
170
|
+
await self._send_message(
|
|
171
|
+
record.chat_id,
|
|
172
|
+
f"Voice note too large (max {max_bytes} bytes).",
|
|
173
|
+
thread_id=record.thread_id,
|
|
174
|
+
reply_to=record.message_id,
|
|
175
|
+
)
|
|
176
|
+
return True
|
|
177
|
+
path = self._persist_voice_payload(record, data, file_path=file_path)
|
|
178
|
+
record.download_path = str(path)
|
|
179
|
+
if file_size is not None:
|
|
180
|
+
record.file_size = file_size
|
|
181
|
+
else:
|
|
182
|
+
record.file_size = len(data)
|
|
183
|
+
self._store.update_pending_voice(record)
|
|
184
|
+
data = path.read_bytes()
|
|
185
|
+
try:
|
|
186
|
+
result = await asyncio.to_thread(
|
|
187
|
+
self._voice_service.transcribe,
|
|
188
|
+
data,
|
|
189
|
+
client="telegram",
|
|
190
|
+
filename=record.file_name or path.name,
|
|
191
|
+
content_type=record.mime_type,
|
|
192
|
+
)
|
|
193
|
+
except VoiceServiceError as exc:
|
|
194
|
+
log_event(
|
|
195
|
+
self._logger,
|
|
196
|
+
logging.WARNING,
|
|
197
|
+
"telegram.media.voice.transcribe_failed",
|
|
198
|
+
chat_id=record.chat_id,
|
|
199
|
+
thread_id=record.thread_id,
|
|
200
|
+
message_id=record.message_id,
|
|
201
|
+
reason=exc.reason,
|
|
202
|
+
)
|
|
203
|
+
await self._send_message(
|
|
204
|
+
record.chat_id,
|
|
205
|
+
exc.detail,
|
|
206
|
+
thread_id=record.thread_id,
|
|
207
|
+
reply_to=record.message_id,
|
|
208
|
+
)
|
|
209
|
+
self._remove_voice_file(record)
|
|
210
|
+
return True
|
|
211
|
+
transcript = ""
|
|
212
|
+
if isinstance(result, dict):
|
|
213
|
+
transcript = str(result.get("text") or "")
|
|
214
|
+
transcript = transcript.strip()
|
|
215
|
+
if not transcript:
|
|
216
|
+
await self._send_message(
|
|
217
|
+
record.chat_id,
|
|
218
|
+
"Voice note transcribed to empty text.",
|
|
219
|
+
thread_id=record.thread_id,
|
|
220
|
+
reply_to=record.message_id,
|
|
221
|
+
)
|
|
222
|
+
self._remove_voice_file(record)
|
|
223
|
+
return True
|
|
224
|
+
combined = record.caption.strip()
|
|
225
|
+
if combined:
|
|
226
|
+
combined = f"{combined}\n\n{transcript}"
|
|
227
|
+
else:
|
|
228
|
+
combined = transcript
|
|
229
|
+
log_event(
|
|
230
|
+
self._logger,
|
|
231
|
+
logging.INFO,
|
|
232
|
+
"telegram.media.voice.transcribed",
|
|
233
|
+
chat_id=record.chat_id,
|
|
234
|
+
thread_id=record.thread_id,
|
|
235
|
+
message_id=record.message_id,
|
|
236
|
+
text_len=len(transcript),
|
|
237
|
+
)
|
|
238
|
+
record.transcript_text = combined
|
|
239
|
+
self._store.update_pending_voice(record)
|
|
240
|
+
await self._deliver_transcript(record, combined)
|
|
241
|
+
self._remove_voice_file(record)
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
async def _record_failure(
|
|
245
|
+
self,
|
|
246
|
+
record: PendingVoiceRecord,
|
|
247
|
+
exc: Exception,
|
|
248
|
+
*,
|
|
249
|
+
retry_after: Optional[int],
|
|
250
|
+
) -> None:
|
|
251
|
+
record.attempts += 1
|
|
252
|
+
record.last_error = str(exc)[:500]
|
|
253
|
+
record.last_attempt_at = now_iso()
|
|
254
|
+
delay = self._retry_delay(record.attempts, retry_after=retry_after)
|
|
255
|
+
record.next_attempt_at = _format_future_time(delay)
|
|
256
|
+
self._store.update_pending_voice(record)
|
|
257
|
+
log_event(
|
|
258
|
+
self._logger,
|
|
259
|
+
logging.WARNING,
|
|
260
|
+
"telegram.voice.retry",
|
|
261
|
+
record_id=record.record_id,
|
|
262
|
+
chat_id=record.chat_id,
|
|
263
|
+
thread_id=record.thread_id,
|
|
264
|
+
attempts=record.attempts,
|
|
265
|
+
retry_after=retry_after,
|
|
266
|
+
next_attempt_at=record.next_attempt_at,
|
|
267
|
+
exc=exc,
|
|
268
|
+
)
|
|
269
|
+
if record.attempts == 1 and record.progress_message_id is None:
|
|
270
|
+
progress_id = await self._send_progress_message(
|
|
271
|
+
record,
|
|
272
|
+
"Queued voice note, retrying download...",
|
|
273
|
+
)
|
|
274
|
+
if progress_id is not None:
|
|
275
|
+
record.progress_message_id = progress_id
|
|
276
|
+
self._store.update_pending_voice(record)
|
|
277
|
+
if record.attempts >= VOICE_MAX_ATTEMPTS:
|
|
278
|
+
await self._give_up(
|
|
279
|
+
record,
|
|
280
|
+
"Voice transcription failed after retries. Please resend.",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
async def _give_up(self, record: PendingVoiceRecord, message: str) -> None:
|
|
284
|
+
if record.progress_message_id is not None:
|
|
285
|
+
await self._edit_message_text(
|
|
286
|
+
record.chat_id,
|
|
287
|
+
record.progress_message_id,
|
|
288
|
+
message,
|
|
289
|
+
)
|
|
290
|
+
else:
|
|
291
|
+
await self._send_message(
|
|
292
|
+
record.chat_id,
|
|
293
|
+
message,
|
|
294
|
+
thread_id=record.thread_id,
|
|
295
|
+
reply_to=record.message_id,
|
|
296
|
+
)
|
|
297
|
+
self._remove_voice_file(record)
|
|
298
|
+
self._store.delete_pending_voice(record.record_id)
|
|
299
|
+
log_event(
|
|
300
|
+
self._logger,
|
|
301
|
+
logging.WARNING,
|
|
302
|
+
"telegram.voice.gave_up",
|
|
303
|
+
record_id=record.record_id,
|
|
304
|
+
chat_id=record.chat_id,
|
|
305
|
+
thread_id=record.thread_id,
|
|
306
|
+
attempts=record.attempts,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
async def _mark_inflight(self, record_id: str) -> bool:
|
|
310
|
+
if self._lock is None:
|
|
311
|
+
self._lock = asyncio.Lock()
|
|
312
|
+
async with self._lock:
|
|
313
|
+
if record_id in self._inflight:
|
|
314
|
+
return False
|
|
315
|
+
self._inflight.add(record_id)
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
async def _clear_inflight(self, record_id: str) -> None:
|
|
319
|
+
if self._lock is None:
|
|
320
|
+
return
|
|
321
|
+
async with self._lock:
|
|
322
|
+
self._inflight.discard(record_id)
|
|
323
|
+
|
|
324
|
+
def _ready_for_attempt(self, record: PendingVoiceRecord) -> bool:
|
|
325
|
+
next_attempt = _parse_iso_timestamp(record.next_attempt_at)
|
|
326
|
+
if next_attempt is None:
|
|
327
|
+
return True
|
|
328
|
+
return datetime.now(timezone.utc) >= next_attempt
|
|
329
|
+
|
|
330
|
+
def _retry_delay(self, attempts: int, *, retry_after: Optional[int]) -> float:
|
|
331
|
+
if retry_after is not None and retry_after > 0:
|
|
332
|
+
return float(retry_after) + VOICE_RETRY_AFTER_BUFFER_SECONDS
|
|
333
|
+
delay: float = VOICE_RETRY_INITIAL_SECONDS * (2 ** max(attempts - 1, 0))
|
|
334
|
+
delay = float(min(delay, VOICE_RETRY_MAX_SECONDS))
|
|
335
|
+
jitter = delay * VOICE_RETRY_JITTER_RATIO
|
|
336
|
+
if jitter:
|
|
337
|
+
delay += random.uniform(0, jitter)
|
|
338
|
+
return delay
|
|
339
|
+
|
|
340
|
+
def _resolve_voice_download_path(
|
|
341
|
+
self, record: PendingVoiceRecord
|
|
342
|
+
) -> Optional[Path]:
|
|
343
|
+
if not record.download_path:
|
|
344
|
+
return None
|
|
345
|
+
path = Path(record.download_path)
|
|
346
|
+
if path.exists():
|
|
347
|
+
return path
|
|
348
|
+
record.download_path = None
|
|
349
|
+
self._store.update_pending_voice(record)
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
def _persist_voice_payload(
|
|
353
|
+
self,
|
|
354
|
+
record: PendingVoiceRecord,
|
|
355
|
+
data: bytes,
|
|
356
|
+
*,
|
|
357
|
+
file_path: Optional[str],
|
|
358
|
+
) -> Path:
|
|
359
|
+
workspace_path = record.workspace_path or str(self._config.root)
|
|
360
|
+
storage_dir = self._voice_storage_dir(workspace_path)
|
|
361
|
+
storage_dir.mkdir(parents=True, exist_ok=True)
|
|
362
|
+
token = secrets.token_hex(6)
|
|
363
|
+
ext = self._choose_voice_extension(
|
|
364
|
+
record.file_name,
|
|
365
|
+
record.mime_type,
|
|
366
|
+
file_path=file_path,
|
|
367
|
+
)
|
|
368
|
+
name = f"telegram-voice-{int(time.time())}-{token}{ext}"
|
|
369
|
+
path = storage_dir / name
|
|
370
|
+
path.write_bytes(data)
|
|
371
|
+
return path
|
|
372
|
+
|
|
373
|
+
def _voice_storage_dir(self, workspace_path: str) -> Path:
|
|
374
|
+
return Path(workspace_path) / ".codex-autorunner" / "uploads" / "telegram-voice"
|
|
375
|
+
|
|
376
|
+
def _choose_voice_extension(
|
|
377
|
+
self,
|
|
378
|
+
file_name: Optional[str],
|
|
379
|
+
mime_type: Optional[str],
|
|
380
|
+
*,
|
|
381
|
+
file_path: Optional[str],
|
|
382
|
+
) -> str:
|
|
383
|
+
for candidate in (file_name, file_path):
|
|
384
|
+
if candidate:
|
|
385
|
+
suffix = Path(candidate).suffix
|
|
386
|
+
if suffix:
|
|
387
|
+
return suffix
|
|
388
|
+
if mime_type == "audio/ogg":
|
|
389
|
+
return ".ogg"
|
|
390
|
+
if mime_type == "audio/opus":
|
|
391
|
+
return ".opus"
|
|
392
|
+
if mime_type == "audio/mpeg":
|
|
393
|
+
return ".mp3"
|
|
394
|
+
if mime_type == "audio/wav":
|
|
395
|
+
return ".wav"
|
|
396
|
+
return ".dat"
|
|
397
|
+
|
|
398
|
+
def _remove_voice_file(self, record: PendingVoiceRecord) -> None:
|
|
399
|
+
if not record.download_path:
|
|
400
|
+
return
|
|
401
|
+
path = Path(record.download_path)
|
|
402
|
+
try:
|
|
403
|
+
path.unlink()
|
|
404
|
+
except FileNotFoundError:
|
|
405
|
+
pass
|
|
406
|
+
except Exception:
|
|
407
|
+
log_event(
|
|
408
|
+
self._logger,
|
|
409
|
+
logging.WARNING,
|
|
410
|
+
"telegram.voice.cleanup_failed",
|
|
411
|
+
record_id=record.record_id,
|
|
412
|
+
path=str(path),
|
|
413
|
+
)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, List, Optional, cast
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
MANIFEST_VERSION = 2
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ManifestError(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclasses.dataclass
|
|
16
|
+
class ManifestRepo:
|
|
17
|
+
id: str
|
|
18
|
+
path: Path # relative to hub root
|
|
19
|
+
enabled: bool = True
|
|
20
|
+
auto_run: bool = False
|
|
21
|
+
kind: str = "base" # base|worktree
|
|
22
|
+
worktree_of: Optional[str] = None
|
|
23
|
+
branch: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
def to_dict(self, hub_root: Path) -> Dict[str, object]:
|
|
26
|
+
rel = _relative_to_hub_root(hub_root, self.path)
|
|
27
|
+
payload: Dict[str, object] = {
|
|
28
|
+
"id": self.id,
|
|
29
|
+
"path": rel.as_posix(),
|
|
30
|
+
"enabled": bool(self.enabled),
|
|
31
|
+
"auto_run": bool(self.auto_run),
|
|
32
|
+
"kind": str(self.kind),
|
|
33
|
+
}
|
|
34
|
+
if self.worktree_of:
|
|
35
|
+
payload["worktree_of"] = str(self.worktree_of)
|
|
36
|
+
if self.branch:
|
|
37
|
+
payload["branch"] = str(self.branch)
|
|
38
|
+
return payload
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclasses.dataclass
|
|
42
|
+
class Manifest:
|
|
43
|
+
version: int
|
|
44
|
+
repos: List[ManifestRepo]
|
|
45
|
+
|
|
46
|
+
def get(self, repo_id: str) -> Optional[ManifestRepo]:
|
|
47
|
+
for repo in self.repos:
|
|
48
|
+
if repo.id == repo_id:
|
|
49
|
+
return repo
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def ensure_repo(
|
|
53
|
+
self,
|
|
54
|
+
hub_root: Path,
|
|
55
|
+
repo_path: Path,
|
|
56
|
+
repo_id: Optional[str] = None,
|
|
57
|
+
*,
|
|
58
|
+
kind: str = "base",
|
|
59
|
+
worktree_of: Optional[str] = None,
|
|
60
|
+
branch: Optional[str] = None,
|
|
61
|
+
) -> ManifestRepo:
|
|
62
|
+
repo_id = repo_id or repo_path.name
|
|
63
|
+
existing = self.get(repo_id)
|
|
64
|
+
if existing:
|
|
65
|
+
return existing
|
|
66
|
+
normalized_path = _relative_to_hub_root(hub_root, repo_path)
|
|
67
|
+
repo = ManifestRepo(
|
|
68
|
+
id=repo_id,
|
|
69
|
+
path=normalized_path,
|
|
70
|
+
enabled=True,
|
|
71
|
+
auto_run=False,
|
|
72
|
+
kind=str(kind),
|
|
73
|
+
worktree_of=str(worktree_of) if worktree_of else None,
|
|
74
|
+
branch=str(branch) if branch else None,
|
|
75
|
+
)
|
|
76
|
+
self.repos.append(repo)
|
|
77
|
+
return repo
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _relative_to_hub_root(hub_root: Path, target: Path) -> Path:
|
|
81
|
+
if not target.is_absolute():
|
|
82
|
+
target = (hub_root / target).resolve()
|
|
83
|
+
else:
|
|
84
|
+
target = target.resolve()
|
|
85
|
+
try:
|
|
86
|
+
return target.relative_to(hub_root)
|
|
87
|
+
except ValueError:
|
|
88
|
+
return Path(os.path.relpath(target, hub_root))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_manifest(manifest_path: Path, hub_root: Path) -> Manifest:
|
|
92
|
+
if not manifest_path.exists():
|
|
93
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
manifest = Manifest(version=MANIFEST_VERSION, repos=[])
|
|
95
|
+
save_manifest(manifest_path, manifest, hub_root)
|
|
96
|
+
return manifest
|
|
97
|
+
|
|
98
|
+
with manifest_path.open("r", encoding="utf-8") as f:
|
|
99
|
+
raw_data = yaml.safe_load(f) or {}
|
|
100
|
+
if not isinstance(raw_data, dict):
|
|
101
|
+
raw_data = {}
|
|
102
|
+
data = cast(Dict[str, Any], raw_data)
|
|
103
|
+
|
|
104
|
+
version = data.get("version")
|
|
105
|
+
if version != MANIFEST_VERSION:
|
|
106
|
+
raise ManifestError(
|
|
107
|
+
f"Unsupported manifest version {version}; expected {MANIFEST_VERSION}"
|
|
108
|
+
)
|
|
109
|
+
repos_data = data.get("repos", []) or []
|
|
110
|
+
if not isinstance(repos_data, list):
|
|
111
|
+
repos_data = []
|
|
112
|
+
repos: List[ManifestRepo] = []
|
|
113
|
+
for entry in repos_data:
|
|
114
|
+
if not isinstance(entry, dict):
|
|
115
|
+
continue
|
|
116
|
+
repo_id = entry.get("id")
|
|
117
|
+
path_val = entry.get("path")
|
|
118
|
+
if not isinstance(repo_id, str) or not repo_id:
|
|
119
|
+
continue
|
|
120
|
+
if not isinstance(path_val, str) or not path_val:
|
|
121
|
+
continue
|
|
122
|
+
kind = entry.get("kind")
|
|
123
|
+
if kind not in ("base", "worktree"):
|
|
124
|
+
raise ManifestError(
|
|
125
|
+
f"Invalid manifest repo kind for {repo_id}: {kind} (expected base|worktree)"
|
|
126
|
+
)
|
|
127
|
+
repos.append(
|
|
128
|
+
ManifestRepo(
|
|
129
|
+
id=repo_id,
|
|
130
|
+
path=_relative_to_hub_root(hub_root, hub_root / path_val),
|
|
131
|
+
enabled=bool(entry.get("enabled", True)),
|
|
132
|
+
auto_run=bool(entry.get("auto_run", False)),
|
|
133
|
+
kind=str(kind),
|
|
134
|
+
worktree_of=(
|
|
135
|
+
str(entry.get("worktree_of")) if entry.get("worktree_of") else None
|
|
136
|
+
),
|
|
137
|
+
branch=str(entry.get("branch")) if entry.get("branch") else None,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
return Manifest(version=MANIFEST_VERSION, repos=repos)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def save_manifest(manifest_path: Path, manifest: Manifest, hub_root: Path) -> None:
|
|
144
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
payload = {
|
|
146
|
+
"version": MANIFEST_VERSION,
|
|
147
|
+
"repos": [repo.to_dict(hub_root) for repo in manifest.repos],
|
|
148
|
+
}
|
|
149
|
+
with manifest_path.open("w", encoding="utf-8") as f:
|
|
150
|
+
yaml.safe_dump(payload, f, sort_keys=False)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Modular API routes for the codex-autorunner server.
|
|
3
|
+
|
|
4
|
+
This package splits the monolithic api_routes.py into focused modules:
|
|
5
|
+
- base: Index, state streaming, and general endpoints
|
|
6
|
+
- docs: Document management (read/write) and chat
|
|
7
|
+
- github: GitHub integration endpoints
|
|
8
|
+
- repos: Run control (start/stop/resume/reset)
|
|
9
|
+
- sessions: Terminal session registry endpoints
|
|
10
|
+
- voice: Voice transcription and config
|
|
11
|
+
- terminal_images: Terminal image uploads
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from fastapi import APIRouter
|
|
17
|
+
|
|
18
|
+
from .base import build_base_routes
|
|
19
|
+
from .docs import build_docs_routes
|
|
20
|
+
from .github import build_github_routes
|
|
21
|
+
from .repos import build_repos_routes
|
|
22
|
+
from .sessions import build_sessions_routes
|
|
23
|
+
from .system import build_system_routes
|
|
24
|
+
from .terminal_images import build_terminal_image_routes
|
|
25
|
+
from .voice import build_voice_routes
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_repo_router(static_dir: Path) -> APIRouter:
|
|
29
|
+
"""
|
|
30
|
+
Build the complete API router by combining all route modules.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
static_dir: Path to the static assets directory
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Combined APIRouter with all endpoints
|
|
37
|
+
"""
|
|
38
|
+
router = APIRouter()
|
|
39
|
+
|
|
40
|
+
# Include all route modules
|
|
41
|
+
router.include_router(build_base_routes(static_dir))
|
|
42
|
+
router.include_router(build_docs_routes())
|
|
43
|
+
router.include_router(build_github_routes())
|
|
44
|
+
router.include_router(build_repos_routes())
|
|
45
|
+
router.include_router(build_sessions_routes())
|
|
46
|
+
router.include_router(build_system_routes())
|
|
47
|
+
router.include_router(build_terminal_image_routes())
|
|
48
|
+
router.include_router(build_voice_routes())
|
|
49
|
+
|
|
50
|
+
return router
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = ["build_repo_router"]
|