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.
Files changed (147) hide show
  1. codex_autorunner/__init__.py +3 -0
  2. codex_autorunner/bootstrap.py +151 -0
  3. codex_autorunner/cli.py +886 -0
  4. codex_autorunner/codex_cli.py +79 -0
  5. codex_autorunner/codex_runner.py +17 -0
  6. codex_autorunner/core/__init__.py +1 -0
  7. codex_autorunner/core/about_car.py +125 -0
  8. codex_autorunner/core/codex_runner.py +100 -0
  9. codex_autorunner/core/config.py +1465 -0
  10. codex_autorunner/core/doc_chat.py +547 -0
  11. codex_autorunner/core/docs.py +37 -0
  12. codex_autorunner/core/engine.py +720 -0
  13. codex_autorunner/core/git_utils.py +206 -0
  14. codex_autorunner/core/hub.py +756 -0
  15. codex_autorunner/core/injected_context.py +9 -0
  16. codex_autorunner/core/locks.py +57 -0
  17. codex_autorunner/core/logging_utils.py +158 -0
  18. codex_autorunner/core/notifications.py +465 -0
  19. codex_autorunner/core/optional_dependencies.py +41 -0
  20. codex_autorunner/core/prompt.py +107 -0
  21. codex_autorunner/core/prompts.py +275 -0
  22. codex_autorunner/core/request_context.py +21 -0
  23. codex_autorunner/core/runner_controller.py +116 -0
  24. codex_autorunner/core/runner_process.py +29 -0
  25. codex_autorunner/core/snapshot.py +576 -0
  26. codex_autorunner/core/state.py +156 -0
  27. codex_autorunner/core/update.py +567 -0
  28. codex_autorunner/core/update_runner.py +44 -0
  29. codex_autorunner/core/usage.py +1221 -0
  30. codex_autorunner/core/utils.py +108 -0
  31. codex_autorunner/discovery.py +102 -0
  32. codex_autorunner/housekeeping.py +423 -0
  33. codex_autorunner/integrations/__init__.py +1 -0
  34. codex_autorunner/integrations/app_server/__init__.py +6 -0
  35. codex_autorunner/integrations/app_server/client.py +1386 -0
  36. codex_autorunner/integrations/app_server/supervisor.py +206 -0
  37. codex_autorunner/integrations/github/__init__.py +10 -0
  38. codex_autorunner/integrations/github/service.py +889 -0
  39. codex_autorunner/integrations/telegram/__init__.py +1 -0
  40. codex_autorunner/integrations/telegram/adapter.py +1401 -0
  41. codex_autorunner/integrations/telegram/commands_registry.py +104 -0
  42. codex_autorunner/integrations/telegram/config.py +450 -0
  43. codex_autorunner/integrations/telegram/constants.py +154 -0
  44. codex_autorunner/integrations/telegram/dispatch.py +162 -0
  45. codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
  46. codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
  47. codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
  48. codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
  49. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
  50. codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
  51. codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
  52. codex_autorunner/integrations/telegram/helpers.py +2084 -0
  53. codex_autorunner/integrations/telegram/notifications.py +164 -0
  54. codex_autorunner/integrations/telegram/outbox.py +174 -0
  55. codex_autorunner/integrations/telegram/rendering.py +102 -0
  56. codex_autorunner/integrations/telegram/retry.py +37 -0
  57. codex_autorunner/integrations/telegram/runtime.py +270 -0
  58. codex_autorunner/integrations/telegram/service.py +921 -0
  59. codex_autorunner/integrations/telegram/state.py +1223 -0
  60. codex_autorunner/integrations/telegram/transport.py +318 -0
  61. codex_autorunner/integrations/telegram/types.py +57 -0
  62. codex_autorunner/integrations/telegram/voice.py +413 -0
  63. codex_autorunner/manifest.py +150 -0
  64. codex_autorunner/routes/__init__.py +53 -0
  65. codex_autorunner/routes/base.py +470 -0
  66. codex_autorunner/routes/docs.py +275 -0
  67. codex_autorunner/routes/github.py +197 -0
  68. codex_autorunner/routes/repos.py +121 -0
  69. codex_autorunner/routes/sessions.py +137 -0
  70. codex_autorunner/routes/shared.py +137 -0
  71. codex_autorunner/routes/system.py +175 -0
  72. codex_autorunner/routes/terminal_images.py +107 -0
  73. codex_autorunner/routes/voice.py +128 -0
  74. codex_autorunner/server.py +23 -0
  75. codex_autorunner/spec_ingest.py +113 -0
  76. codex_autorunner/static/app.js +95 -0
  77. codex_autorunner/static/autoRefresh.js +209 -0
  78. codex_autorunner/static/bootstrap.js +105 -0
  79. codex_autorunner/static/bus.js +23 -0
  80. codex_autorunner/static/cache.js +52 -0
  81. codex_autorunner/static/constants.js +48 -0
  82. codex_autorunner/static/dashboard.js +795 -0
  83. codex_autorunner/static/docs.js +1514 -0
  84. codex_autorunner/static/env.js +99 -0
  85. codex_autorunner/static/github.js +168 -0
  86. codex_autorunner/static/hub.js +1511 -0
  87. codex_autorunner/static/index.html +622 -0
  88. codex_autorunner/static/loader.js +28 -0
  89. codex_autorunner/static/logs.js +690 -0
  90. codex_autorunner/static/mobileCompact.js +300 -0
  91. codex_autorunner/static/snapshot.js +116 -0
  92. codex_autorunner/static/state.js +87 -0
  93. codex_autorunner/static/styles.css +4966 -0
  94. codex_autorunner/static/tabs.js +50 -0
  95. codex_autorunner/static/terminal.js +21 -0
  96. codex_autorunner/static/terminalManager.js +3535 -0
  97. codex_autorunner/static/todoPreview.js +25 -0
  98. codex_autorunner/static/types.d.ts +8 -0
  99. codex_autorunner/static/utils.js +597 -0
  100. codex_autorunner/static/vendor/LICENSE.xterm +24 -0
  101. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
  102. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
  103. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
  104. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
  105. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
  106. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
  107. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
  108. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
  109. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
  110. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
  111. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
  112. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
  113. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
  114. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
  115. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
  116. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
  117. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
  118. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
  119. codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
  120. codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
  121. codex_autorunner/static/vendor/xterm.css +209 -0
  122. codex_autorunner/static/vendor/xterm.js +2 -0
  123. codex_autorunner/static/voice.js +591 -0
  124. codex_autorunner/voice/__init__.py +39 -0
  125. codex_autorunner/voice/capture.py +349 -0
  126. codex_autorunner/voice/config.py +167 -0
  127. codex_autorunner/voice/provider.py +66 -0
  128. codex_autorunner/voice/providers/__init__.py +7 -0
  129. codex_autorunner/voice/providers/openai_whisper.py +345 -0
  130. codex_autorunner/voice/resolver.py +36 -0
  131. codex_autorunner/voice/service.py +210 -0
  132. codex_autorunner/web/__init__.py +1 -0
  133. codex_autorunner/web/app.py +1037 -0
  134. codex_autorunner/web/hub_jobs.py +181 -0
  135. codex_autorunner/web/middleware.py +552 -0
  136. codex_autorunner/web/pty_session.py +357 -0
  137. codex_autorunner/web/runner_manager.py +25 -0
  138. codex_autorunner/web/schemas.py +253 -0
  139. codex_autorunner/web/static_assets.py +430 -0
  140. codex_autorunner/web/terminal_sessions.py +78 -0
  141. codex_autorunner/workspace.py +16 -0
  142. codex_autorunner-0.1.0.dist-info/METADATA +240 -0
  143. codex_autorunner-0.1.0.dist-info/RECORD +147 -0
  144. codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
  145. codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
  146. codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
  147. 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"]