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,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
|