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,137 @@
1
+ """
2
+ Terminal session registry routes.
3
+ """
4
+
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from fastapi import APIRouter, HTTPException, Request
9
+
10
+ from ..core.state import persist_session_registry
11
+ from ..web.schemas import (
12
+ SessionsResponse,
13
+ SessionStopRequest,
14
+ SessionStopResponse,
15
+ )
16
+
17
+
18
+ def _relative_repo_path(repo_path: str, repo_root: Path) -> str:
19
+ path = Path(repo_path)
20
+ if not path.is_absolute():
21
+ return repo_path
22
+ try:
23
+ rel = path.resolve().relative_to(repo_root)
24
+ return rel.as_posix() or "."
25
+ except Exception:
26
+ return path.name
27
+
28
+
29
+ def _allow_abs_paths(request: Request, include_abs_paths: bool) -> bool:
30
+ if not include_abs_paths:
31
+ return False
32
+ return bool(getattr(request.app.state, "auth_token", None))
33
+
34
+
35
+ def _session_payload(
36
+ session_id: str,
37
+ record,
38
+ terminal_sessions: dict,
39
+ repo_root: Path,
40
+ include_abs_paths: bool,
41
+ ) -> dict:
42
+ active = terminal_sessions.get(session_id)
43
+ alive = bool(active and active.pty.isalive())
44
+ payload = {
45
+ "session_id": session_id,
46
+ "repo_path": _relative_repo_path(record.repo_path, repo_root),
47
+ "created_at": record.created_at,
48
+ "last_seen_at": record.last_seen_at,
49
+ "status": record.status,
50
+ "alive": alive,
51
+ }
52
+ if include_abs_paths:
53
+ payload["abs_repo_path"] = record.repo_path
54
+ return payload
55
+
56
+
57
+ def build_sessions_routes() -> APIRouter:
58
+ router = APIRouter()
59
+
60
+ @router.get("/api/sessions", response_model=SessionsResponse)
61
+ def list_sessions(request: Request, include_abs_paths: bool = False):
62
+ terminal_sessions = request.app.state.terminal_sessions
63
+ session_registry = request.app.state.session_registry
64
+ repo_to_session = request.app.state.repo_to_session
65
+ repo_root = Path(request.app.state.engine.repo_root)
66
+ allow_abs = _allow_abs_paths(request, include_abs_paths)
67
+ sessions = [
68
+ _session_payload(
69
+ session_id, record, terminal_sessions, repo_root, allow_abs
70
+ )
71
+ for session_id, record in session_registry.items()
72
+ ]
73
+ repo_to_session_payload = {
74
+ _relative_repo_path(repo_path, repo_root): session_id
75
+ for repo_path, session_id in repo_to_session.items()
76
+ }
77
+ payload = {
78
+ "sessions": sessions,
79
+ "repo_to_session": repo_to_session_payload,
80
+ }
81
+ if allow_abs:
82
+ payload["abs_repo_to_session"] = dict(repo_to_session)
83
+ return {
84
+ **payload,
85
+ }
86
+
87
+ @router.post("/api/sessions/stop", response_model=SessionStopResponse)
88
+ async def stop_session(request: Request, payload: SessionStopRequest):
89
+ session_id = payload.session_id
90
+ repo_path = payload.repo_path
91
+ if not session_id and not repo_path:
92
+ raise HTTPException(
93
+ status_code=400, detail="Provide session_id or repo_path"
94
+ )
95
+
96
+ terminal_sessions = request.app.state.terminal_sessions
97
+ session_registry = request.app.state.session_registry
98
+ repo_to_session = request.app.state.repo_to_session
99
+ terminal_lock = request.app.state.terminal_lock
100
+ engine = request.app.state.engine
101
+
102
+ if repo_path and isinstance(repo_path, str):
103
+ repo_root = Path(request.app.state.engine.repo_root)
104
+ normalized_repo_path = repo_path.strip()
105
+ if normalized_repo_path:
106
+ candidate = Path(normalized_repo_path)
107
+ if not candidate.is_absolute():
108
+ candidate = (repo_root / candidate).resolve()
109
+ normalized_repo_path = str(candidate)
110
+ session_id = repo_to_session.get(
111
+ normalized_repo_path
112
+ ) or repo_to_session.get(repo_path)
113
+ if not isinstance(session_id, str) or not session_id:
114
+ raise HTTPException(status_code=404, detail="Session not found")
115
+ if session_id not in session_registry and session_id not in terminal_sessions:
116
+ raise HTTPException(status_code=404, detail="Session not found")
117
+
118
+ async with terminal_lock:
119
+ session = terminal_sessions.get(session_id)
120
+ if session:
121
+ session.close()
122
+ await session.wait_closed()
123
+ terminal_sessions.pop(session_id, None)
124
+ session_registry.pop(session_id, None)
125
+ repo_to_session = {
126
+ repo: sid for repo, sid in repo_to_session.items() if sid != session_id
127
+ }
128
+ request.app.state.repo_to_session = repo_to_session
129
+ persist_session_registry(
130
+ engine.state_path, session_registry, repo_to_session
131
+ )
132
+ request.app.state.session_state_last_write = time.time()
133
+ request.app.state.session_state_dirty = False
134
+
135
+ return {"status": "stopped", "session_id": session_id}
136
+
137
+ return router
@@ -0,0 +1,137 @@
1
+ """
2
+ Shared utilities for route modules.
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from ..codex_cli import extract_flag_value
12
+ from ..core.locks import process_alive, read_lock_info
13
+ from ..core.state import load_state
14
+
15
+ BYPASS_FLAGS = {
16
+ "--yolo",
17
+ "--dangerously-bypass-approvals-and-sandbox",
18
+ }
19
+
20
+
21
+ def _extract_bypass_flag(args: list[str]) -> tuple[str, list[str]]:
22
+ chosen = None
23
+ for arg in args:
24
+ if arg in BYPASS_FLAGS:
25
+ chosen = arg
26
+ break
27
+ filtered = [arg for arg in args if arg not in BYPASS_FLAGS]
28
+ return chosen or "--yolo", filtered
29
+
30
+
31
+ def build_codex_terminal_cmd(engine, *, resume_mode: bool) -> list[str]:
32
+ """
33
+ Build the subprocess argv for launching the Codex interactive CLI inside a PTY.
34
+ """
35
+ bypass_flag, terminal_args = _extract_bypass_flag(
36
+ list(engine.config.codex_terminal_args)
37
+ )
38
+ if resume_mode:
39
+ return [
40
+ engine.config.codex_binary,
41
+ bypass_flag,
42
+ "resume",
43
+ *terminal_args,
44
+ ]
45
+
46
+ cmd = [
47
+ engine.config.codex_binary,
48
+ bypass_flag,
49
+ *terminal_args,
50
+ ]
51
+ return cmd
52
+
53
+
54
+ def resolve_runner_status(engine, state) -> tuple[str, Optional[int], bool]:
55
+ pid = state.runner_pid
56
+ alive_pid = pid if pid and process_alive(pid) else None
57
+ if alive_pid is None:
58
+ info = read_lock_info(engine.lock_path)
59
+ if info.pid and process_alive(info.pid):
60
+ alive_pid = info.pid
61
+ running = alive_pid is not None
62
+ status = state.status
63
+ if status == "running" and not running:
64
+ status = "idle"
65
+ runner_pid = alive_pid if running else None
66
+ return status, runner_pid, running
67
+
68
+
69
+ async def log_stream(log_path: Path, heartbeat_interval: float = 15.0):
70
+ """SSE stream generator for log file tailing."""
71
+ if not log_path.exists():
72
+ yield "data: log file not found\n\n"
73
+ return
74
+ last_emit_at = time.monotonic()
75
+ with log_path.open("r", encoding="utf-8") as f:
76
+ f.seek(0, 2)
77
+ while True:
78
+ line = f.readline()
79
+ if line:
80
+ yield f"data: {line.rstrip()}\n\n"
81
+ last_emit_at = time.monotonic()
82
+ else:
83
+ now = time.monotonic()
84
+ if now - last_emit_at >= heartbeat_interval:
85
+ yield ": ping\n\n"
86
+ last_emit_at = now
87
+ await asyncio.sleep(0.5)
88
+
89
+
90
+ async def state_stream(engine, manager, logger=None, heartbeat_interval: float = 15.0):
91
+ """SSE stream generator for state updates."""
92
+ last_payload = None
93
+ last_error_log_at = 0.0
94
+ last_emit_at = time.monotonic()
95
+ terminal_idle_timeout_seconds = engine.config.terminal_idle_timeout_seconds
96
+ codex_model = engine.config.codex_model or extract_flag_value(
97
+ engine.config.codex_args, "--model"
98
+ )
99
+ while True:
100
+ emitted = False
101
+ try:
102
+ state = await asyncio.to_thread(load_state, engine.state_path)
103
+ outstanding, done = await asyncio.to_thread(engine.docs.todos)
104
+ status, runner_pid, running = resolve_runner_status(engine, state)
105
+ payload = {
106
+ "last_run_id": state.last_run_id,
107
+ "status": status,
108
+ "last_exit_code": state.last_exit_code,
109
+ "last_run_started_at": state.last_run_started_at,
110
+ "last_run_finished_at": state.last_run_finished_at,
111
+ "outstanding_count": len(outstanding),
112
+ "done_count": len(done),
113
+ "running": running,
114
+ "runner_pid": runner_pid,
115
+ "terminal_idle_timeout_seconds": terminal_idle_timeout_seconds,
116
+ "codex_model": codex_model or "auto",
117
+ }
118
+ if payload != last_payload:
119
+ yield f"data: {json.dumps(payload)}\n\n"
120
+ last_payload = payload
121
+ last_emit_at = time.monotonic()
122
+ emitted = True
123
+ except Exception:
124
+ # Don't spam logs, but don't swallow silently either.
125
+ now = time.time()
126
+ if logger is not None and (now - last_error_log_at) > 60:
127
+ last_error_log_at = now
128
+ try:
129
+ logger.warning("state stream error", exc_info=True)
130
+ except Exception:
131
+ pass
132
+ if not emitted:
133
+ now = time.monotonic()
134
+ if now - last_emit_at >= heartbeat_interval:
135
+ yield ": ping\n\n"
136
+ last_emit_at = now
137
+ await asyncio.sleep(1.0)
@@ -0,0 +1,175 @@
1
+ import asyncio
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from fastapi import APIRouter, HTTPException, Request
7
+ from fastapi.responses import JSONResponse
8
+
9
+ from ..core import update as update_core
10
+ from ..core.config import HubConfig
11
+ from ..core.update import (
12
+ UpdateInProgressError,
13
+ _normalize_update_ref,
14
+ _normalize_update_target,
15
+ _read_update_status,
16
+ _spawn_update_process,
17
+ _system_update_check,
18
+ )
19
+ from ..web.schemas import (
20
+ SystemHealthResponse,
21
+ SystemUpdateCheckResponse,
22
+ SystemUpdateRequest,
23
+ SystemUpdateResponse,
24
+ SystemUpdateStatusResponse,
25
+ )
26
+ from ..web.static_assets import missing_static_assets
27
+
28
+ _pid_is_running = update_core._pid_is_running
29
+ _system_update_worker = update_core._system_update_worker
30
+ _update_lock_active = update_core._update_lock_active
31
+ _update_lock_path = update_core._update_lock_path
32
+ _update_status_path = update_core._update_status_path
33
+ shutil = update_core.shutil
34
+ subprocess = update_core.subprocess
35
+
36
+
37
+ def build_system_routes() -> APIRouter:
38
+ router = APIRouter()
39
+
40
+ @router.get("/health", response_model=SystemHealthResponse)
41
+ async def system_health(request: Request):
42
+ try:
43
+ config = request.app.state.config
44
+ except AttributeError:
45
+ config = None
46
+ mode = "hub" if isinstance(config, HubConfig) else "repo"
47
+ base_path = getattr(request.app.state, "base_path", "")
48
+ asset_version = getattr(request.app.state, "asset_version", None)
49
+ static_dir = getattr(getattr(request.app, "state", None), "static_dir", None)
50
+ if not isinstance(static_dir, Path):
51
+ return JSONResponse(
52
+ {
53
+ "status": "error",
54
+ "detail": "Static UI assets missing; reinstall package",
55
+ "mode": mode,
56
+ "base_path": base_path,
57
+ },
58
+ status_code=500,
59
+ )
60
+ missing = await asyncio.to_thread(missing_static_assets, static_dir)
61
+ if missing:
62
+ return JSONResponse(
63
+ {
64
+ "status": "error",
65
+ "detail": "Static UI assets missing; reinstall package",
66
+ "missing": missing,
67
+ "mode": mode,
68
+ "base_path": base_path,
69
+ },
70
+ status_code=500,
71
+ )
72
+ return {
73
+ "status": "ok",
74
+ "mode": mode,
75
+ "base_path": base_path,
76
+ "asset_version": asset_version,
77
+ }
78
+
79
+ @router.get("/system/update/check", response_model=SystemUpdateCheckResponse)
80
+ async def system_update_check(request: Request):
81
+ """
82
+ Check if an update is available by comparing local git state vs remote.
83
+ If local git state is unavailable, report that an update may be available.
84
+ """
85
+ try:
86
+ config = request.app.state.config
87
+ except AttributeError:
88
+ config = None
89
+
90
+ repo_url = "https://github.com/Git-on-my-level/codex-autorunner.git"
91
+ repo_ref = "main"
92
+ if config and isinstance(config, HubConfig):
93
+ configured_url = getattr(config, "update_repo_url", None)
94
+ if configured_url:
95
+ repo_url = configured_url
96
+ configured_ref = getattr(config, "update_repo_ref", None)
97
+ if configured_ref:
98
+ repo_ref = configured_ref
99
+
100
+ try:
101
+ return await asyncio.to_thread(
102
+ _system_update_check, repo_url=repo_url, repo_ref=repo_ref
103
+ )
104
+ except Exception as e:
105
+ logger = getattr(getattr(request.app, "state", None), "logger", None)
106
+ if logger:
107
+ logger.error("Update check error: %s", e, exc_info=True)
108
+ raise HTTPException(status_code=500, detail=str(e)) from e
109
+
110
+ @router.post("/system/update", response_model=SystemUpdateResponse)
111
+ async def system_update(
112
+ request: Request, payload: Optional[SystemUpdateRequest] = None
113
+ ):
114
+ """
115
+ Pull latest code and refresh the running service.
116
+ This will restart the server if successful.
117
+ """
118
+ try:
119
+ config = request.app.state.config
120
+ except AttributeError:
121
+ config = None
122
+
123
+ # Determine URL
124
+ repo_url = "https://github.com/Git-on-my-level/codex-autorunner.git"
125
+ repo_ref = "main"
126
+ if config and isinstance(config, HubConfig):
127
+ configured_url = getattr(config, "update_repo_url", None)
128
+ if configured_url:
129
+ repo_url = configured_url
130
+ configured_ref = getattr(config, "update_repo_ref", None)
131
+ if configured_ref:
132
+ repo_ref = configured_ref
133
+
134
+ home_dot_car = Path.home() / ".codex-autorunner"
135
+ update_dir = home_dot_car / "update_cache"
136
+
137
+ try:
138
+ target_raw = payload.target if payload else None
139
+ if target_raw is None:
140
+ target_raw = request.query_params.get("target")
141
+ update_target = _normalize_update_target(target_raw)
142
+ logger = getattr(getattr(request.app, "state", None), "logger", None)
143
+ if logger is None:
144
+ logger = logging.getLogger("codex_autorunner.system_update")
145
+ await asyncio.to_thread(
146
+ _spawn_update_process,
147
+ repo_url=repo_url,
148
+ repo_ref=_normalize_update_ref(repo_ref),
149
+ update_dir=update_dir,
150
+ logger=logger,
151
+ update_target=update_target,
152
+ )
153
+ return {
154
+ "status": "ok",
155
+ "message": f"Update started ({update_target}). Service will restart shortly.",
156
+ "target": update_target,
157
+ }
158
+ except UpdateInProgressError as exc:
159
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
160
+ except ValueError as exc:
161
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
162
+ except Exception as e:
163
+ logger = getattr(getattr(request.app, "state", None), "logger", None)
164
+ if logger:
165
+ logger.error("Update error: %s", e, exc_info=True)
166
+ raise HTTPException(status_code=500, detail=str(e)) from e
167
+
168
+ @router.get("/system/update/status", response_model=SystemUpdateStatusResponse)
169
+ async def system_update_status():
170
+ status = await asyncio.to_thread(_read_update_status)
171
+ if status is None:
172
+ return {"status": "unknown", "message": "No update status recorded."}
173
+ return status
174
+
175
+ return router
@@ -0,0 +1,107 @@
1
+ """
2
+ Terminal image upload routes.
3
+ """
4
+
5
+ import secrets
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from fastapi import APIRouter, File, HTTPException, Request, UploadFile
11
+
12
+ MAX_IMAGE_BYTES = 10 * 1024 * 1024
13
+ ALLOWED_CONTENT_TYPES = {
14
+ "image/png": ".png",
15
+ "image/jpeg": ".jpg",
16
+ "image/jpg": ".jpg",
17
+ "image/gif": ".gif",
18
+ "image/webp": ".webp",
19
+ "image/heic": ".heic",
20
+ "image/heif": ".heif",
21
+ }
22
+ ALLOWED_EXTS = set(ALLOWED_CONTENT_TYPES.values())
23
+
24
+
25
+ def _choose_image_extension(
26
+ filename: Optional[str], content_type: Optional[str]
27
+ ) -> str:
28
+ if filename:
29
+ suffix = Path(filename).suffix.lower()
30
+ if suffix in ALLOWED_EXTS:
31
+ return suffix
32
+ if content_type:
33
+ mapped = ALLOWED_CONTENT_TYPES.get(content_type.lower())
34
+ if mapped:
35
+ return mapped
36
+ return ".img"
37
+
38
+
39
+ def build_terminal_image_routes() -> APIRouter:
40
+ router = APIRouter()
41
+
42
+ def _allow_abs_paths(request: Request, include_abs_paths: bool) -> bool:
43
+ if not include_abs_paths:
44
+ return False
45
+ return bool(getattr(request.app.state, "auth_token", None))
46
+
47
+ @router.post("/api/terminal/image")
48
+ async def upload_terminal_image(
49
+ request: Request,
50
+ file: UploadFile = File(...),
51
+ include_abs_paths: bool = False,
52
+ ):
53
+ if not file:
54
+ raise HTTPException(status_code=400, detail="missing image")
55
+
56
+ content_type = (file.content_type or "").lower()
57
+ content_type = content_type.split(";", 1)[0].strip()
58
+ filename = file.filename or ""
59
+ suffix = Path(filename).suffix.lower()
60
+ looks_like_image = False
61
+ if content_type.startswith("image/"):
62
+ looks_like_image = True
63
+ elif suffix in ALLOWED_EXTS:
64
+ looks_like_image = True
65
+ if not looks_like_image:
66
+ raise HTTPException(status_code=400, detail="unsupported content type")
67
+
68
+ try:
69
+ data = await file.read()
70
+ except Exception as exc:
71
+ raise HTTPException(
72
+ status_code=400, detail="unable to read upload"
73
+ ) from exc
74
+
75
+ if not data:
76
+ raise HTTPException(status_code=400, detail="empty upload")
77
+ if len(data) > MAX_IMAGE_BYTES:
78
+ raise HTTPException(status_code=413, detail="image too large")
79
+
80
+ engine = request.app.state.engine
81
+ repo_root = Path(engine.repo_root)
82
+ images_dir = repo_root / ".codex-autorunner" / "uploads" / "terminal-images"
83
+ images_dir.mkdir(parents=True, exist_ok=True)
84
+
85
+ ext = _choose_image_extension(filename, content_type)
86
+ token = secrets.token_hex(6)
87
+ name = f"terminal-{int(time.time())}-{token}{ext}"
88
+ path = images_dir / name
89
+ try:
90
+ path.write_bytes(data)
91
+ except Exception as exc:
92
+ raise HTTPException(status_code=500, detail="failed to save image") from exc
93
+
94
+ rel_path = path.relative_to(repo_root).as_posix()
95
+ payload = {
96
+ "status": "ok",
97
+ "path": rel_path,
98
+ "filename": name,
99
+ }
100
+ if _allow_abs_paths(request, include_abs_paths):
101
+ payload["abs_path"] = str(path)
102
+ return payload
103
+
104
+ return router
105
+
106
+
107
+ __all__ = ["build_terminal_image_routes"]
@@ -0,0 +1,128 @@
1
+ """
2
+ Voice transcription and configuration routes.
3
+ """
4
+
5
+ import asyncio
6
+ from typing import Optional
7
+
8
+ from fastapi import APIRouter, File, HTTPException, Request, UploadFile
9
+
10
+ from ..voice import VoiceService, VoiceServiceError
11
+
12
+
13
+ def build_voice_routes() -> APIRouter:
14
+ """Build routes for voice transcription and config."""
15
+ router = APIRouter()
16
+
17
+ @router.get("/api/voice/config")
18
+ def get_voice_config(request: Request):
19
+ voice_service: Optional[VoiceService] = request.app.state.voice_service
20
+ voice_config = request.app.state.voice_config
21
+ missing_reason = getattr(request.app.state, "voice_missing_reason", None)
22
+ if missing_reason:
23
+ return {
24
+ "enabled": False,
25
+ "provider": voice_config.provider,
26
+ "latency_mode": voice_config.latency_mode,
27
+ "chunk_ms": voice_config.chunk_ms,
28
+ "sample_rate": voice_config.sample_rate,
29
+ "warn_on_remote_api": voice_config.warn_on_remote_api,
30
+ "has_api_key": False,
31
+ "api_key_env": (
32
+ voice_config.providers.get(
33
+ voice_config.provider or "openai_whisper", {}
34
+ )
35
+ or {}
36
+ ).get("api_key_env", "OPENAI_API_KEY"),
37
+ "push_to_talk": {
38
+ "max_ms": voice_config.push_to_talk.max_ms,
39
+ "silence_auto_stop_ms": voice_config.push_to_talk.silence_auto_stop_ms,
40
+ "min_hold_ms": voice_config.push_to_talk.min_hold_ms,
41
+ },
42
+ "missing_extra": missing_reason,
43
+ }
44
+ if voice_service is None:
45
+ # Degrade gracefully: still return config to the UI even if service init failed.
46
+ try:
47
+ return VoiceService(
48
+ voice_config, logger=request.app.state.logger
49
+ ).config_payload()
50
+ except Exception:
51
+ return {
52
+ "enabled": False,
53
+ "provider": voice_config.provider,
54
+ "latency_mode": voice_config.latency_mode,
55
+ "chunk_ms": voice_config.chunk_ms,
56
+ "sample_rate": voice_config.sample_rate,
57
+ "warn_on_remote_api": voice_config.warn_on_remote_api,
58
+ "has_api_key": False,
59
+ "api_key_env": (
60
+ voice_config.providers.get(
61
+ voice_config.provider or "openai_whisper", {}
62
+ )
63
+ or {}
64
+ ).get("api_key_env", "OPENAI_API_KEY"),
65
+ "push_to_talk": {
66
+ "max_ms": voice_config.push_to_talk.max_ms,
67
+ "silence_auto_stop_ms": voice_config.push_to_talk.silence_auto_stop_ms,
68
+ "min_hold_ms": voice_config.push_to_talk.min_hold_ms,
69
+ },
70
+ }
71
+ return voice_service.config_payload()
72
+
73
+ @router.post("/api/voice/transcribe")
74
+ async def transcribe_voice(
75
+ request: Request,
76
+ file: Optional[UploadFile] = File(None),
77
+ language: Optional[str] = None,
78
+ ):
79
+ voice_service: Optional[VoiceService] = request.app.state.voice_service
80
+ voice_config = request.app.state.voice_config
81
+ missing_reason = getattr(request.app.state, "voice_missing_reason", None)
82
+ if missing_reason:
83
+ raise HTTPException(status_code=503, detail=missing_reason)
84
+ if not voice_service or not voice_config.enabled:
85
+ raise HTTPException(status_code=400, detail="Voice is disabled")
86
+
87
+ filename: Optional[str] = None
88
+ content_type: Optional[str] = None
89
+ if file is not None:
90
+ filename = file.filename
91
+ content_type = file.content_type
92
+ try:
93
+ audio_bytes = await file.read()
94
+ except Exception as exc:
95
+ raise HTTPException(
96
+ status_code=400, detail="Unable to read audio upload"
97
+ ) from exc
98
+ else:
99
+ audio_bytes = await request.body()
100
+ try:
101
+ result = await asyncio.to_thread(
102
+ voice_service.transcribe,
103
+ audio_bytes,
104
+ client="web",
105
+ user_agent=request.headers.get("user-agent"),
106
+ language=language,
107
+ filename=filename,
108
+ content_type=content_type,
109
+ )
110
+ except VoiceServiceError as exc:
111
+ if exc.reason == "unauthorized":
112
+ status = 401
113
+ elif exc.reason == "forbidden":
114
+ status = 403
115
+ elif exc.reason == "audio_too_large":
116
+ status = 413
117
+ elif exc.reason == "rate_limited":
118
+ status = 429
119
+ else:
120
+ status = (
121
+ 400
122
+ if exc.reason in ("disabled", "empty_audio", "invalid_audio")
123
+ else 502
124
+ )
125
+ raise HTTPException(status_code=status, detail=exc.detail) from exc
126
+ return {"status": "ok", **result}
127
+
128
+ return router