codex-autorunner 0.1.2__py3-none-any.whl → 1.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 (276) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/__main__.py +4 -0
  3. codex_autorunner/agents/codex/harness.py +1 -1
  4. codex_autorunner/agents/opencode/client.py +68 -35
  5. codex_autorunner/agents/opencode/constants.py +3 -0
  6. codex_autorunner/agents/opencode/harness.py +6 -1
  7. codex_autorunner/agents/opencode/logging.py +21 -5
  8. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  9. codex_autorunner/agents/opencode/runtime.py +176 -47
  10. codex_autorunner/agents/opencode/supervisor.py +36 -48
  11. codex_autorunner/agents/registry.py +155 -8
  12. codex_autorunner/api.py +25 -0
  13. codex_autorunner/bootstrap.py +22 -37
  14. codex_autorunner/cli.py +5 -1156
  15. codex_autorunner/codex_cli.py +20 -84
  16. codex_autorunner/core/__init__.py +4 -0
  17. codex_autorunner/core/about_car.py +49 -32
  18. codex_autorunner/core/adapter_utils.py +21 -0
  19. codex_autorunner/core/app_server_ids.py +59 -0
  20. codex_autorunner/core/app_server_logging.py +7 -3
  21. codex_autorunner/core/app_server_prompts.py +27 -260
  22. codex_autorunner/core/app_server_threads.py +26 -28
  23. codex_autorunner/core/app_server_utils.py +165 -0
  24. codex_autorunner/core/archive.py +349 -0
  25. codex_autorunner/core/codex_runner.py +12 -2
  26. codex_autorunner/core/config.py +587 -103
  27. codex_autorunner/core/docs.py +10 -2
  28. codex_autorunner/core/drafts.py +136 -0
  29. codex_autorunner/core/engine.py +1531 -866
  30. codex_autorunner/core/exceptions.py +4 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +202 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +88 -0
  35. codex_autorunner/core/flows/reasons.py +52 -0
  36. codex_autorunner/core/flows/reconciler.py +131 -0
  37. codex_autorunner/core/flows/runtime.py +382 -0
  38. codex_autorunner/core/flows/store.py +568 -0
  39. codex_autorunner/core/flows/transition.py +138 -0
  40. codex_autorunner/core/flows/ux_helpers.py +257 -0
  41. codex_autorunner/core/flows/worker_process.py +242 -0
  42. codex_autorunner/core/git_utils.py +62 -0
  43. codex_autorunner/core/hub.py +136 -16
  44. codex_autorunner/core/locks.py +4 -0
  45. codex_autorunner/core/notifications.py +14 -2
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/core/ports/agent_backend.py +150 -0
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/core/ports/run_event.py +91 -0
  50. codex_autorunner/core/prompt.py +15 -7
  51. codex_autorunner/core/redaction.py +29 -0
  52. codex_autorunner/core/review_context.py +5 -8
  53. codex_autorunner/core/run_index.py +6 -0
  54. codex_autorunner/core/runner_process.py +5 -2
  55. codex_autorunner/core/state.py +0 -88
  56. codex_autorunner/core/state_roots.py +57 -0
  57. codex_autorunner/core/supervisor_protocol.py +15 -0
  58. codex_autorunner/core/supervisor_utils.py +67 -0
  59. codex_autorunner/core/text_delta_coalescer.py +54 -0
  60. codex_autorunner/core/ticket_linter_cli.py +201 -0
  61. codex_autorunner/core/ticket_manager_cli.py +432 -0
  62. codex_autorunner/core/update.py +24 -16
  63. codex_autorunner/core/update_paths.py +28 -0
  64. codex_autorunner/core/update_runner.py +2 -0
  65. codex_autorunner/core/usage.py +164 -12
  66. codex_autorunner/core/utils.py +120 -11
  67. codex_autorunner/discovery.py +2 -4
  68. codex_autorunner/flows/review/__init__.py +17 -0
  69. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  70. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  71. codex_autorunner/flows/ticket_flow/definition.py +98 -0
  72. codex_autorunner/integrations/agents/__init__.py +17 -0
  73. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  74. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  75. codex_autorunner/integrations/agents/codex_backend.py +448 -0
  76. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  77. codex_autorunner/integrations/agents/opencode_backend.py +598 -0
  78. codex_autorunner/integrations/agents/runner.py +91 -0
  79. codex_autorunner/integrations/agents/wiring.py +271 -0
  80. codex_autorunner/integrations/app_server/client.py +583 -152
  81. codex_autorunner/integrations/app_server/env.py +2 -107
  82. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  83. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  84. codex_autorunner/integrations/telegram/adapter.py +204 -165
  85. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  86. codex_autorunner/integrations/telegram/config.py +221 -0
  87. codex_autorunner/integrations/telegram/constants.py +17 -2
  88. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  89. codex_autorunner/integrations/telegram/doctor.py +47 -0
  90. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
  91. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  92. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  93. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  94. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
  95. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  96. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  97. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  98. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
  99. codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
  100. codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
  101. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  102. codex_autorunner/integrations/telegram/helpers.py +111 -16
  103. codex_autorunner/integrations/telegram/outbox.py +208 -37
  104. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  105. codex_autorunner/integrations/telegram/service.py +221 -42
  106. codex_autorunner/integrations/telegram/state.py +100 -2
  107. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
  108. codex_autorunner/integrations/telegram/transport.py +39 -4
  109. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  110. codex_autorunner/manifest.py +2 -0
  111. codex_autorunner/plugin_api.py +22 -0
  112. codex_autorunner/routes/__init__.py +37 -67
  113. codex_autorunner/routes/agents.py +2 -137
  114. codex_autorunner/routes/analytics.py +3 -0
  115. codex_autorunner/routes/app_server.py +2 -131
  116. codex_autorunner/routes/base.py +2 -624
  117. codex_autorunner/routes/file_chat.py +7 -0
  118. codex_autorunner/routes/flows.py +7 -0
  119. codex_autorunner/routes/messages.py +7 -0
  120. codex_autorunner/routes/repos.py +2 -196
  121. codex_autorunner/routes/review.py +2 -147
  122. codex_autorunner/routes/sessions.py +2 -175
  123. codex_autorunner/routes/settings.py +2 -168
  124. codex_autorunner/routes/shared.py +2 -275
  125. codex_autorunner/routes/system.py +4 -188
  126. codex_autorunner/routes/usage.py +3 -0
  127. codex_autorunner/routes/voice.py +2 -119
  128. codex_autorunner/routes/workspace.py +3 -0
  129. codex_autorunner/server.py +3 -2
  130. codex_autorunner/static/agentControls.js +41 -11
  131. codex_autorunner/static/agentEvents.js +248 -0
  132. codex_autorunner/static/app.js +35 -24
  133. codex_autorunner/static/archive.js +826 -0
  134. codex_autorunner/static/archiveApi.js +37 -0
  135. codex_autorunner/static/autoRefresh.js +36 -8
  136. codex_autorunner/static/bootstrap.js +1 -0
  137. codex_autorunner/static/bus.js +1 -0
  138. codex_autorunner/static/cache.js +1 -0
  139. codex_autorunner/static/constants.js +20 -4
  140. codex_autorunner/static/dashboard.js +344 -325
  141. codex_autorunner/static/diffRenderer.js +37 -0
  142. codex_autorunner/static/docChatCore.js +324 -0
  143. codex_autorunner/static/docChatStorage.js +65 -0
  144. codex_autorunner/static/docChatVoice.js +65 -0
  145. codex_autorunner/static/docEditor.js +133 -0
  146. codex_autorunner/static/env.js +1 -0
  147. codex_autorunner/static/eventSummarizer.js +166 -0
  148. codex_autorunner/static/fileChat.js +182 -0
  149. codex_autorunner/static/health.js +155 -0
  150. codex_autorunner/static/hub.js +126 -185
  151. codex_autorunner/static/index.html +839 -863
  152. codex_autorunner/static/liveUpdates.js +1 -0
  153. codex_autorunner/static/loader.js +1 -0
  154. codex_autorunner/static/messages.js +873 -0
  155. codex_autorunner/static/mobileCompact.js +2 -1
  156. codex_autorunner/static/preserve.js +17 -0
  157. codex_autorunner/static/settings.js +149 -217
  158. codex_autorunner/static/smartRefresh.js +52 -0
  159. codex_autorunner/static/styles.css +8850 -3876
  160. codex_autorunner/static/tabs.js +175 -11
  161. codex_autorunner/static/terminal.js +32 -0
  162. codex_autorunner/static/terminalManager.js +34 -59
  163. codex_autorunner/static/ticketChatActions.js +333 -0
  164. codex_autorunner/static/ticketChatEvents.js +16 -0
  165. codex_autorunner/static/ticketChatStorage.js +16 -0
  166. codex_autorunner/static/ticketChatStream.js +264 -0
  167. codex_autorunner/static/ticketEditor.js +844 -0
  168. codex_autorunner/static/ticketVoice.js +9 -0
  169. codex_autorunner/static/tickets.js +1988 -0
  170. codex_autorunner/static/utils.js +43 -3
  171. codex_autorunner/static/voice.js +1 -0
  172. codex_autorunner/static/workspace.js +765 -0
  173. codex_autorunner/static/workspaceApi.js +53 -0
  174. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  175. codex_autorunner/surfaces/__init__.py +5 -0
  176. codex_autorunner/surfaces/cli/__init__.py +6 -0
  177. codex_autorunner/surfaces/cli/cli.py +1224 -0
  178. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  179. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  180. codex_autorunner/surfaces/web/__init__.py +1 -0
  181. codex_autorunner/surfaces/web/app.py +2019 -0
  182. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  183. codex_autorunner/surfaces/web/middleware.py +587 -0
  184. codex_autorunner/surfaces/web/pty_session.py +370 -0
  185. codex_autorunner/surfaces/web/review.py +6 -0
  186. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  187. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  188. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  189. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  190. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  191. codex_autorunner/surfaces/web/routes/base.py +615 -0
  192. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  193. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  194. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  195. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  196. codex_autorunner/surfaces/web/routes/review.py +148 -0
  197. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  198. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  199. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  200. codex_autorunner/surfaces/web/routes/system.py +196 -0
  201. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  202. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  203. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  204. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  205. codex_autorunner/surfaces/web/schemas.py +417 -0
  206. codex_autorunner/surfaces/web/static_assets.py +490 -0
  207. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  208. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  209. codex_autorunner/tickets/__init__.py +27 -0
  210. codex_autorunner/tickets/agent_pool.py +399 -0
  211. codex_autorunner/tickets/files.py +89 -0
  212. codex_autorunner/tickets/frontmatter.py +55 -0
  213. codex_autorunner/tickets/lint.py +102 -0
  214. codex_autorunner/tickets/models.py +97 -0
  215. codex_autorunner/tickets/outbox.py +244 -0
  216. codex_autorunner/tickets/replies.py +179 -0
  217. codex_autorunner/tickets/runner.py +881 -0
  218. codex_autorunner/tickets/spec_ingest.py +77 -0
  219. codex_autorunner/web/__init__.py +5 -1
  220. codex_autorunner/web/app.py +2 -1771
  221. codex_autorunner/web/hub_jobs.py +2 -191
  222. codex_autorunner/web/middleware.py +2 -587
  223. codex_autorunner/web/pty_session.py +2 -369
  224. codex_autorunner/web/runner_manager.py +2 -24
  225. codex_autorunner/web/schemas.py +2 -396
  226. codex_autorunner/web/static_assets.py +4 -484
  227. codex_autorunner/web/static_refresh.py +2 -85
  228. codex_autorunner/web/terminal_sessions.py +2 -77
  229. codex_autorunner/workspace/__init__.py +40 -0
  230. codex_autorunner/workspace/paths.py +335 -0
  231. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  232. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  233. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
  234. codex_autorunner/agents/execution/policy.py +0 -292
  235. codex_autorunner/agents/factory.py +0 -52
  236. codex_autorunner/agents/orchestrator.py +0 -358
  237. codex_autorunner/core/doc_chat.py +0 -1446
  238. codex_autorunner/core/snapshot.py +0 -580
  239. codex_autorunner/integrations/github/chatops.py +0 -268
  240. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  241. codex_autorunner/routes/docs.py +0 -381
  242. codex_autorunner/routes/github.py +0 -327
  243. codex_autorunner/routes/runs.py +0 -250
  244. codex_autorunner/spec_ingest.py +0 -812
  245. codex_autorunner/static/docChatActions.js +0 -287
  246. codex_autorunner/static/docChatEvents.js +0 -300
  247. codex_autorunner/static/docChatRender.js +0 -205
  248. codex_autorunner/static/docChatStream.js +0 -361
  249. codex_autorunner/static/docs.js +0 -20
  250. codex_autorunner/static/docsClipboard.js +0 -69
  251. codex_autorunner/static/docsCrud.js +0 -257
  252. codex_autorunner/static/docsDocUpdates.js +0 -62
  253. codex_autorunner/static/docsDrafts.js +0 -16
  254. codex_autorunner/static/docsElements.js +0 -69
  255. codex_autorunner/static/docsInit.js +0 -285
  256. codex_autorunner/static/docsParse.js +0 -160
  257. codex_autorunner/static/docsSnapshot.js +0 -87
  258. codex_autorunner/static/docsSpecIngest.js +0 -263
  259. codex_autorunner/static/docsState.js +0 -127
  260. codex_autorunner/static/docsThreadRegistry.js +0 -44
  261. codex_autorunner/static/docsUi.js +0 -153
  262. codex_autorunner/static/docsVoice.js +0 -56
  263. codex_autorunner/static/github.js +0 -504
  264. codex_autorunner/static/logs.js +0 -678
  265. codex_autorunner/static/review.js +0 -157
  266. codex_autorunner/static/runs.js +0 -418
  267. codex_autorunner/static/snapshot.js +0 -124
  268. codex_autorunner/static/state.js +0 -94
  269. codex_autorunner/static/todoPreview.js +0 -27
  270. codex_autorunner/workspace.py +0 -16
  271. codex_autorunner-0.1.2.dist-info/METADATA +0 -249
  272. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  273. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  274. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  275. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  276. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -1,1772 +1,3 @@
1
- import asyncio
2
- import logging
3
- import os
4
- import shlex
5
- import threading
6
- from contextlib import ExitStack, asynccontextmanager
7
- from dataclasses import dataclass
8
- from pathlib import Path
9
- from typing import Mapping, Optional
1
+ """Backward-compatible app exports."""
10
2
 
11
- from fastapi import FastAPI, HTTPException
12
- from fastapi.responses import HTMLResponse
13
- from fastapi.staticfiles import StaticFiles
14
- from starlette.middleware.gzip import GZipMiddleware
15
- from starlette.routing import Mount
16
- from starlette.types import ASGIApp
17
-
18
- from ..agents.opencode.supervisor import OpenCodeSupervisor
19
- from ..core.app_server_events import AppServerEventBuffer
20
- from ..core.app_server_threads import (
21
- AppServerThreadRegistry,
22
- default_app_server_threads_path,
23
- )
24
- from ..core.config import (
25
- AppServerConfig,
26
- ConfigError,
27
- HubConfig,
28
- _is_loopback_host,
29
- _normalize_base_path,
30
- derive_repo_config,
31
- load_hub_config,
32
- load_repo_config,
33
- resolve_env_for_root,
34
- )
35
- from ..core.doc_chat import DocChatService
36
- from ..core.engine import Engine, LockError
37
- from ..core.hub import HubSupervisor
38
- from ..core.logging_utils import safe_log, setup_rotating_logger
39
- from ..core.optional_dependencies import require_optional_dependencies
40
- from ..core.request_context import get_request_id
41
- from ..core.snapshot import SnapshotService
42
- from ..core.state import load_state, persist_session_registry
43
- from ..core.usage import (
44
- UsageError,
45
- default_codex_home,
46
- get_hub_usage_series_cached,
47
- get_hub_usage_summary_cached,
48
- parse_iso_datetime,
49
- )
50
- from ..core.utils import (
51
- build_opencode_supervisor,
52
- )
53
- from ..housekeeping import run_housekeeping_once
54
- from ..integrations.app_server.client import ApprovalHandler, NotificationHandler
55
- from ..integrations.app_server.env import build_app_server_env
56
- from ..integrations.app_server.supervisor import WorkspaceAppServerSupervisor
57
- from ..integrations.github.chatops import GitHubChatOpsPoller
58
- from ..integrations.github.pr_flow import PrFlowManager
59
- from ..manifest import load_manifest
60
- from ..routes import build_repo_router
61
- from ..routes.system import build_system_routes
62
- from ..spec_ingest import SpecIngestService
63
- from ..voice import VoiceConfig, VoiceService
64
- from .hub_jobs import HubJobManager
65
- from .middleware import (
66
- AuthTokenMiddleware,
67
- BasePathRouterMiddleware,
68
- HostOriginMiddleware,
69
- RequestIdMiddleware,
70
- SecurityHeadersMiddleware,
71
- )
72
- from .runner_manager import RunnerManager
73
- from .schemas import (
74
- HubCleanupWorktreeRequest,
75
- HubCreateRepoRequest,
76
- HubCreateWorktreeRequest,
77
- HubJobResponse,
78
- HubRemoveRepoRequest,
79
- RunControlRequest,
80
- )
81
- from .static_assets import (
82
- asset_version,
83
- index_response_headers,
84
- materialize_static_assets,
85
- render_index_html,
86
- require_static_assets,
87
- )
88
- from .terminal_sessions import parse_tui_idle_seconds, prune_terminal_registry
89
-
90
-
91
- @dataclass(frozen=True)
92
- class AppContext:
93
- base_path: str
94
- env: Mapping[str, str]
95
- engine: Engine
96
- manager: RunnerManager
97
- doc_chat: DocChatService
98
- spec_ingest: SpecIngestService
99
- snapshot_service: SnapshotService
100
- app_server_supervisor: Optional[WorkspaceAppServerSupervisor]
101
- app_server_prune_interval: Optional[float]
102
- app_server_threads: AppServerThreadRegistry
103
- app_server_events: AppServerEventBuffer
104
- opencode_supervisor: Optional[OpenCodeSupervisor]
105
- opencode_prune_interval: Optional[float]
106
- voice_config: VoiceConfig
107
- voice_missing_reason: Optional[str]
108
- voice_service: Optional[VoiceService]
109
- terminal_sessions: dict
110
- terminal_max_idle_seconds: Optional[float]
111
- terminal_lock: asyncio.Lock
112
- session_registry: dict
113
- repo_to_session: dict
114
- session_state_last_write: float
115
- session_state_dirty: bool
116
- static_dir: Path
117
- static_assets_context: Optional[object]
118
- asset_version: str
119
- logger: logging.Logger
120
- tui_idle_seconds: Optional[float]
121
- tui_idle_check_seconds: Optional[float]
122
-
123
-
124
- @dataclass(frozen=True)
125
- class HubAppContext:
126
- base_path: str
127
- config: HubConfig
128
- supervisor: HubSupervisor
129
- job_manager: HubJobManager
130
- app_server_supervisor: Optional[WorkspaceAppServerSupervisor]
131
- app_server_prune_interval: Optional[float]
132
- static_dir: Path
133
- static_assets_context: Optional[object]
134
- asset_version: str
135
- logger: logging.Logger
136
-
137
-
138
- @dataclass(frozen=True)
139
- class ServerOverrides:
140
- allowed_hosts: Optional[list[str]] = None
141
- allowed_origins: Optional[list[str]] = None
142
- auth_token_env: Optional[str] = None
143
-
144
-
145
- def _app_server_prune_interval(idle_ttl_seconds: Optional[int]) -> Optional[float]:
146
- if not idle_ttl_seconds or idle_ttl_seconds <= 0:
147
- return None
148
- return float(min(600.0, max(60.0, idle_ttl_seconds / 2)))
149
-
150
-
151
- def _normalize_approval_path(path: str, repo_root: Path) -> str:
152
- raw = (path or "").strip()
153
- if not raw:
154
- return ""
155
- if raw.startswith(("a/", "b/")):
156
- raw = raw[2:]
157
- if raw.startswith("./"):
158
- raw = raw[2:]
159
- candidate = Path(raw)
160
- if candidate.is_absolute():
161
- try:
162
- candidate = candidate.relative_to(repo_root)
163
- except ValueError:
164
- return raw
165
- return candidate.as_posix()
166
-
167
-
168
- def _extract_approval_paths(params: dict, *, repo_root: Path) -> list[str]:
169
- paths: list[str] = []
170
-
171
- def _add(entry: object) -> None:
172
- if isinstance(entry, str):
173
- normalized = _normalize_approval_path(entry, repo_root)
174
- if normalized:
175
- paths.append(normalized)
176
- return
177
- if isinstance(entry, dict):
178
- raw = entry.get("path") or entry.get("file") or entry.get("name")
179
- if isinstance(raw, str):
180
- normalized = _normalize_approval_path(raw, repo_root)
181
- if normalized:
182
- paths.append(normalized)
183
-
184
- for payload in (params, params.get("item") if isinstance(params, dict) else None):
185
- if not isinstance(payload, dict):
186
- continue
187
- for key in ("files", "fileChanges", "paths"):
188
- entries = payload.get(key)
189
- if isinstance(entries, list):
190
- for entry in entries:
191
- _add(entry)
192
- for key in ("path", "file", "name"):
193
- _add(payload.get(key))
194
- return paths
195
-
196
-
197
- def _extract_turn_context(params: dict) -> tuple[Optional[str], Optional[str]]:
198
- if not isinstance(params, dict):
199
- return None, None
200
- turn_id = params.get("turnId") or params.get("turn_id") or params.get("id")
201
- thread_id = params.get("threadId") or params.get("thread_id")
202
- turn = params.get("turn")
203
- if isinstance(turn, dict):
204
- turn_id = turn_id or turn.get("id") or turn.get("turnId")
205
- thread_id = thread_id or turn.get("threadId") or turn.get("thread_id")
206
- item = params.get("item")
207
- if isinstance(item, dict):
208
- thread_id = thread_id or item.get("threadId") or item.get("thread_id")
209
- turn_id = str(turn_id) if isinstance(turn_id, str) and turn_id else None
210
- thread_id = str(thread_id) if isinstance(thread_id, str) and thread_id else None
211
- return thread_id, turn_id
212
-
213
-
214
- def _build_app_server_supervisor(
215
- config: AppServerConfig,
216
- *,
217
- logger: logging.Logger,
218
- event_prefix: str,
219
- base_env: Optional[Mapping[str, str]] = None,
220
- notification_handler: Optional[NotificationHandler] = None,
221
- approval_handler: Optional[ApprovalHandler] = None,
222
- ) -> tuple[Optional[WorkspaceAppServerSupervisor], Optional[float]]:
223
- if not config.command:
224
- return None, None
225
-
226
- def _env_builder(
227
- workspace_root: Path, _workspace_id: str, state_dir: Path
228
- ) -> dict[str, str]:
229
- state_dir.mkdir(parents=True, exist_ok=True)
230
- return build_app_server_env(
231
- config.command,
232
- workspace_root,
233
- state_dir,
234
- logger=logger,
235
- event_prefix=event_prefix,
236
- base_env=base_env,
237
- )
238
-
239
- try:
240
- asyncio.get_running_loop()
241
- except RuntimeError:
242
- asyncio.set_event_loop(asyncio.new_event_loop())
243
-
244
- supervisor = WorkspaceAppServerSupervisor(
245
- config.command,
246
- state_root=config.state_root,
247
- env_builder=_env_builder,
248
- logger=logger,
249
- max_handles=config.max_handles,
250
- idle_ttl_seconds=config.idle_ttl_seconds,
251
- request_timeout=config.request_timeout,
252
- notification_handler=notification_handler,
253
- approval_handler=approval_handler,
254
- )
255
- return supervisor, _app_server_prune_interval(config.idle_ttl_seconds)
256
-
257
-
258
- def _parse_command(raw: Optional[str]) -> list[str]:
259
- if not raw:
260
- return []
261
- try:
262
- return [part for part in shlex.split(raw) if part]
263
- except ValueError:
264
- return []
265
-
266
-
267
- def _build_opencode_supervisor(
268
- config: AppServerConfig,
269
- *,
270
- workspace_root: Path,
271
- opencode_binary: Optional[str],
272
- opencode_command: Optional[list[str]],
273
- logger: logging.Logger,
274
- env: Mapping[str, str],
275
- subagent_models: Optional[Mapping[str, str]] = None,
276
- ) -> tuple[Optional[OpenCodeSupervisor], Optional[float]]:
277
- supervisor = build_opencode_supervisor(
278
- opencode_command=opencode_command,
279
- opencode_binary=opencode_binary,
280
- workspace_root=workspace_root,
281
- logger=logger,
282
- request_timeout=config.request_timeout,
283
- max_handles=config.max_handles,
284
- idle_ttl_seconds=config.idle_ttl_seconds,
285
- base_env=env,
286
- subagent_models=subagent_models,
287
- )
288
- if supervisor is None:
289
- safe_log(
290
- logger,
291
- logging.INFO,
292
- "OpenCode command unavailable; skipping opencode supervisor.",
293
- )
294
- return None, None
295
- return supervisor, _app_server_prune_interval(config.idle_ttl_seconds)
296
-
297
-
298
- def _build_app_context(
299
- repo_root: Optional[Path],
300
- base_path: Optional[str],
301
- hub_config: Optional[HubConfig] = None,
302
- ) -> AppContext:
303
- target_root = (repo_root or Path.cwd()).resolve()
304
- if hub_config is None:
305
- config = load_repo_config(target_root)
306
- env = dict(os.environ)
307
- else:
308
- env = resolve_env_for_root(target_root)
309
- config = derive_repo_config(hub_config, target_root, load_env=False)
310
- normalized_base = (
311
- _normalize_base_path(base_path)
312
- if base_path is not None
313
- else config.server_base_path
314
- )
315
- engine = Engine(config.root, config=config)
316
- manager = RunnerManager(engine)
317
- voice_config = VoiceConfig.from_raw(config.voice, env=env)
318
- voice_missing_reason: Optional[str] = None
319
- try:
320
- require_optional_dependencies(
321
- feature="voice",
322
- deps=(
323
- ("httpx", "httpx"),
324
- (("multipart", "python_multipart"), "python-multipart"),
325
- ),
326
- extra="voice",
327
- )
328
- except ConfigError as exc:
329
- voice_missing_reason = str(exc)
330
- voice_config.enabled = False
331
- terminal_max_idle_seconds = config.terminal_idle_timeout_seconds
332
- if terminal_max_idle_seconds is not None and terminal_max_idle_seconds <= 0:
333
- terminal_max_idle_seconds = None
334
- tui_idle_seconds = parse_tui_idle_seconds(config)
335
- tui_idle_check_seconds: Optional[float] = None
336
- if tui_idle_seconds is not None:
337
- tui_idle_check_seconds = min(10.0, max(1.0, tui_idle_seconds / 4))
338
- # Construct asyncio primitives without assuming a loop already exists.
339
- # This comes up in unit tests (sync context) and when mounting from a worker thread.
340
- try:
341
- terminal_lock = asyncio.Lock()
342
- except RuntimeError:
343
- asyncio.set_event_loop(asyncio.new_event_loop())
344
- terminal_lock = asyncio.Lock()
345
- logger = setup_rotating_logger(
346
- f"repo[{engine.repo_root}]", engine.config.server_log
347
- )
348
- engine.notifier.set_logger(logger)
349
- safe_log(
350
- logger,
351
- logging.INFO,
352
- f"Repo server ready at {engine.repo_root}",
353
- )
354
- app_server_events = AppServerEventBuffer()
355
- allowed_doc_paths = {
356
- path
357
- for kind in ("todo", "progress", "opinions", "spec", "summary")
358
- for path in [
359
- _normalize_approval_path(
360
- str(engine.config.doc_path(kind).relative_to(engine.config.root)),
361
- engine.config.root,
362
- )
363
- ]
364
- if path
365
- }
366
-
367
- async def _doc_chat_approval_handler(message: dict) -> str:
368
- method = message.get("method")
369
- params = message.get("params")
370
- params = params if isinstance(params, dict) else {}
371
- thread_id, turn_id = _extract_turn_context(params)
372
- if method == "item/fileChange/requestApproval":
373
- paths = _extract_approval_paths(params, repo_root=engine.config.root)
374
- normalized = [path for path in paths if path]
375
- if not normalized:
376
- notice = "Rejected file change without explicit paths."
377
- await app_server_events.handle_notification(
378
- {
379
- "method": "error",
380
- "params": {
381
- "message": notice,
382
- "turnId": turn_id,
383
- "threadId": thread_id,
384
- },
385
- }
386
- )
387
- return "decline"
388
- rejected = [path for path in normalized if path not in allowed_doc_paths]
389
- if rejected:
390
- notice = "Rejected write to non-doc files: " + ", ".join(rejected)
391
- await app_server_events.handle_notification(
392
- {
393
- "method": "error",
394
- "params": {
395
- "message": notice,
396
- "turnId": turn_id,
397
- "threadId": thread_id,
398
- },
399
- }
400
- )
401
- return "decline"
402
- return "accept"
403
- if method == "item/commandExecution/requestApproval":
404
- notice = "Rejected command execution in doc chat session."
405
- await app_server_events.handle_notification(
406
- {
407
- "method": "error",
408
- "params": {
409
- "message": notice,
410
- "turnId": turn_id,
411
- "threadId": thread_id,
412
- },
413
- }
414
- )
415
- return "decline"
416
- return "decline"
417
-
418
- app_server_supervisor, app_server_prune_interval = _build_app_server_supervisor(
419
- engine.config.app_server,
420
- logger=logger,
421
- event_prefix="web.app_server",
422
- base_env=env,
423
- notification_handler=app_server_events.handle_notification,
424
- approval_handler=_doc_chat_approval_handler,
425
- )
426
- app_server_threads = AppServerThreadRegistry(
427
- default_app_server_threads_path(engine.repo_root)
428
- )
429
- opencode_command = config.agent_serve_command("opencode")
430
- try:
431
- opencode_binary = config.agent_binary("opencode")
432
- except ConfigError:
433
- opencode_binary = None
434
- agent_config = config.agents.get("opencode")
435
- subagent_models = agent_config.subagent_models if agent_config else None
436
- opencode_supervisor, opencode_prune_interval = _build_opencode_supervisor(
437
- config.app_server,
438
- workspace_root=engine.repo_root,
439
- opencode_binary=opencode_binary,
440
- opencode_command=opencode_command,
441
- logger=logger,
442
- env=env,
443
- subagent_models=subagent_models,
444
- )
445
- doc_chat = DocChatService(
446
- engine,
447
- app_server_supervisor=app_server_supervisor,
448
- app_server_threads=app_server_threads,
449
- app_server_events=app_server_events,
450
- opencode_supervisor=opencode_supervisor,
451
- env=env,
452
- )
453
- spec_ingest = SpecIngestService(
454
- engine,
455
- app_server_supervisor=app_server_supervisor,
456
- app_server_threads=app_server_threads,
457
- app_server_events=app_server_events,
458
- opencode_supervisor=opencode_supervisor,
459
- env=env,
460
- )
461
- snapshot_service = SnapshotService(
462
- engine,
463
- app_server_supervisor=app_server_supervisor,
464
- app_server_threads=app_server_threads,
465
- )
466
- voice_service: Optional[VoiceService]
467
- if voice_missing_reason:
468
- voice_service = None
469
- safe_log(
470
- logger,
471
- logging.WARNING,
472
- voice_missing_reason,
473
- )
474
- else:
475
- try:
476
- voice_service = VoiceService(voice_config, logger=logger)
477
- except Exception as exc:
478
- voice_service = None
479
- safe_log(
480
- logger,
481
- logging.WARNING,
482
- "Voice service unavailable",
483
- exc,
484
- )
485
- session_registry: dict = {}
486
- repo_to_session: dict = {}
487
- initial_state = load_state(engine.state_path)
488
- session_registry = dict(initial_state.sessions)
489
- repo_to_session = dict(initial_state.repo_to_session)
490
- # Normalize persisted keys from older/newer versions:
491
- # - Prefer bare repo keys for the default "codex" agent.
492
- # - Preserve `repo:agent` keys for non-default agents (e.g. opencode).
493
- normalized_repo_to_session: dict[str, str] = {}
494
- for raw_key, session_id in repo_to_session.items():
495
- key = str(raw_key)
496
- if ":" in key:
497
- repo, agent = key.split(":", 1)
498
- agent_norm = agent.strip().lower()
499
- if not agent_norm or agent_norm == "codex":
500
- key = repo
501
- else:
502
- key = f"{repo}:{agent_norm}"
503
- # Keep the first mapping we see to avoid surprising overrides.
504
- normalized_repo_to_session.setdefault(key, session_id)
505
- repo_to_session = normalized_repo_to_session
506
- terminal_sessions: dict = {}
507
- if session_registry or repo_to_session:
508
- prune_terminal_registry(
509
- engine.state_path,
510
- terminal_sessions,
511
- session_registry,
512
- repo_to_session,
513
- terminal_max_idle_seconds,
514
- )
515
-
516
- def _load_static_assets(
517
- cache_root: Path, max_cache_entries: int, max_cache_age_days: Optional[int]
518
- ) -> tuple[Path, Optional[ExitStack]]:
519
- static_dir, static_context = materialize_static_assets(
520
- cache_root,
521
- max_cache_entries=max_cache_entries,
522
- max_cache_age_days=max_cache_age_days,
523
- logger=logger,
524
- )
525
- try:
526
- require_static_assets(static_dir, logger)
527
- except Exception as exc:
528
- if static_context is not None:
529
- static_context.close()
530
- safe_log(
531
- logger,
532
- logging.WARNING,
533
- "Static assets requirement check failed",
534
- exc=exc,
535
- )
536
- raise
537
- return static_dir, static_context
538
-
539
- try:
540
- static_dir, static_context = _load_static_assets(
541
- config.static_assets.cache_root,
542
- config.static_assets.max_cache_entries,
543
- config.static_assets.max_cache_age_days,
544
- )
545
- except Exception as exc:
546
- if hub_config is None:
547
- raise
548
- hub_static = hub_config.static_assets
549
- if hub_static.cache_root == config.static_assets.cache_root:
550
- raise
551
- safe_log(
552
- logger,
553
- logging.WARNING,
554
- "Repo static assets unavailable; retrying with hub cache root %s",
555
- hub_static.cache_root,
556
- exc=exc,
557
- )
558
- static_dir, static_context = _load_static_assets(
559
- hub_static.cache_root,
560
- hub_static.max_cache_entries,
561
- hub_static.max_cache_age_days,
562
- )
563
- return AppContext(
564
- base_path=normalized_base,
565
- env=env,
566
- engine=engine,
567
- manager=manager,
568
- doc_chat=doc_chat,
569
- spec_ingest=spec_ingest,
570
- snapshot_service=snapshot_service,
571
- app_server_supervisor=app_server_supervisor,
572
- app_server_prune_interval=app_server_prune_interval,
573
- app_server_threads=app_server_threads,
574
- app_server_events=app_server_events,
575
- opencode_supervisor=opencode_supervisor,
576
- opencode_prune_interval=opencode_prune_interval,
577
- voice_config=voice_config,
578
- voice_missing_reason=voice_missing_reason,
579
- voice_service=voice_service,
580
- terminal_sessions=terminal_sessions,
581
- terminal_max_idle_seconds=terminal_max_idle_seconds,
582
- terminal_lock=terminal_lock,
583
- session_registry=session_registry,
584
- repo_to_session=repo_to_session,
585
- session_state_last_write=0.0,
586
- session_state_dirty=False,
587
- static_dir=static_dir,
588
- static_assets_context=static_context,
589
- asset_version=asset_version(static_dir),
590
- logger=logger,
591
- tui_idle_seconds=tui_idle_seconds,
592
- tui_idle_check_seconds=tui_idle_check_seconds,
593
- )
594
-
595
-
596
- def _apply_app_context(app: FastAPI, context: AppContext) -> None:
597
- app.state.base_path = context.base_path
598
- app.state.env = context.env
599
- app.state.logger = context.logger
600
- app.state.engine = context.engine
601
- app.state.config = context.engine.config # Expose config consistently
602
- app.state.manager = context.manager
603
- app.state.doc_chat = context.doc_chat
604
- app.state.spec_ingest = context.spec_ingest
605
- app.state.snapshot_service = context.snapshot_service
606
- app.state.app_server_supervisor = context.app_server_supervisor
607
- app.state.app_server_prune_interval = context.app_server_prune_interval
608
- app.state.app_server_threads = context.app_server_threads
609
- app.state.app_server_events = context.app_server_events
610
- app.state.opencode_supervisor = context.opencode_supervisor
611
- app.state.opencode_prune_interval = context.opencode_prune_interval
612
- app.state.voice_config = context.voice_config
613
- app.state.voice_missing_reason = context.voice_missing_reason
614
- app.state.voice_service = context.voice_service
615
- app.state.terminal_sessions = context.terminal_sessions
616
- app.state.terminal_max_idle_seconds = context.terminal_max_idle_seconds
617
- app.state.terminal_lock = context.terminal_lock
618
- app.state.session_registry = context.session_registry
619
- app.state.repo_to_session = context.repo_to_session
620
- app.state.session_state_last_write = context.session_state_last_write
621
- app.state.session_state_dirty = context.session_state_dirty
622
- app.state.static_dir = context.static_dir
623
- app.state.static_assets_context = context.static_assets_context
624
- app.state.asset_version = context.asset_version
625
-
626
-
627
- def _build_hub_context(
628
- hub_root: Optional[Path], base_path: Optional[str]
629
- ) -> HubAppContext:
630
- config = load_hub_config(hub_root or Path.cwd())
631
- normalized_base = (
632
- _normalize_base_path(base_path)
633
- if base_path is not None
634
- else config.server_base_path
635
- )
636
- supervisor = HubSupervisor(config)
637
- logger = setup_rotating_logger(f"hub[{config.root}]", config.server_log)
638
- safe_log(
639
- logger,
640
- logging.INFO,
641
- f"Hub app ready at {config.root}",
642
- )
643
- app_server_supervisor, app_server_prune_interval = _build_app_server_supervisor(
644
- config.app_server,
645
- logger=logger,
646
- event_prefix="hub.app_server",
647
- )
648
- static_dir, static_context = materialize_static_assets(
649
- config.static_assets.cache_root,
650
- max_cache_entries=config.static_assets.max_cache_entries,
651
- max_cache_age_days=config.static_assets.max_cache_age_days,
652
- logger=logger,
653
- )
654
- try:
655
- require_static_assets(static_dir, logger)
656
- except Exception as exc:
657
- if static_context is not None:
658
- static_context.close()
659
- safe_log(
660
- logger,
661
- logging.WARNING,
662
- "Static assets requirement check failed",
663
- exc=exc,
664
- )
665
- raise
666
- return HubAppContext(
667
- base_path=normalized_base,
668
- config=config,
669
- supervisor=supervisor,
670
- job_manager=HubJobManager(logger=logger),
671
- app_server_supervisor=app_server_supervisor,
672
- app_server_prune_interval=app_server_prune_interval,
673
- static_dir=static_dir,
674
- static_assets_context=static_context,
675
- asset_version=asset_version(static_dir),
676
- logger=logger,
677
- )
678
-
679
-
680
- def _apply_hub_context(app: FastAPI, context: HubAppContext) -> None:
681
- app.state.base_path = context.base_path
682
- app.state.logger = context.logger
683
- app.state.config = context.config # Expose config for route modules
684
- app.state.job_manager = context.job_manager
685
- app.state.app_server_supervisor = context.app_server_supervisor
686
- app.state.app_server_prune_interval = context.app_server_prune_interval
687
- app.state.static_dir = context.static_dir
688
- app.state.static_assets_context = context.static_assets_context
689
- app.state.asset_version = context.asset_version
690
-
691
-
692
- def _app_lifespan(context: AppContext):
693
- @asynccontextmanager
694
- async def lifespan(app: FastAPI):
695
- tasks: list[asyncio.Task] = []
696
-
697
- async def _cleanup_loop():
698
- try:
699
- while True:
700
- await asyncio.sleep(600) # Check every 10 mins
701
- try:
702
- async with app.state.terminal_lock:
703
- prune_terminal_registry(
704
- app.state.engine.state_path,
705
- app.state.terminal_sessions,
706
- app.state.session_registry,
707
- app.state.repo_to_session,
708
- app.state.terminal_max_idle_seconds,
709
- )
710
- except Exception as exc:
711
- safe_log(
712
- app.state.logger,
713
- logging.WARNING,
714
- "Terminal cleanup task failed",
715
- exc,
716
- )
717
- except asyncio.CancelledError:
718
- return
719
-
720
- async def _housekeeping_loop():
721
- config = app.state.config.housekeeping
722
- interval = max(config.interval_seconds, 1)
723
- try:
724
- while True:
725
- try:
726
- await asyncio.to_thread(
727
- run_housekeeping_once,
728
- config,
729
- app.state.engine.repo_root,
730
- logger=app.state.logger,
731
- )
732
- except Exception as exc:
733
- safe_log(
734
- app.state.logger,
735
- logging.WARNING,
736
- "Housekeeping task failed",
737
- exc,
738
- )
739
- await asyncio.sleep(interval)
740
- except asyncio.CancelledError:
741
- return
742
-
743
- tasks.append(asyncio.create_task(_cleanup_loop()))
744
- if app.state.config.housekeeping.enabled:
745
- tasks.append(asyncio.create_task(_housekeeping_loop()))
746
- app_server_supervisor = getattr(app.state, "app_server_supervisor", None)
747
- app_server_prune_interval = getattr(
748
- app.state, "app_server_prune_interval", None
749
- )
750
- if app_server_supervisor is not None and app_server_prune_interval:
751
-
752
- async def _app_server_prune_loop():
753
- try:
754
- while True:
755
- await asyncio.sleep(app_server_prune_interval)
756
- try:
757
- await app_server_supervisor.prune_idle()
758
- except Exception as exc:
759
- safe_log(
760
- app.state.logger,
761
- logging.WARNING,
762
- "App-server prune task failed",
763
- exc,
764
- )
765
- except asyncio.CancelledError:
766
- return
767
-
768
- tasks.append(asyncio.create_task(_app_server_prune_loop()))
769
-
770
- opencode_supervisor = getattr(app.state, "opencode_supervisor", None)
771
- opencode_prune_interval = getattr(app.state, "opencode_prune_interval", None)
772
- if opencode_supervisor is not None and opencode_prune_interval:
773
-
774
- async def _opencode_prune_loop():
775
- try:
776
- while True:
777
- await asyncio.sleep(opencode_prune_interval)
778
- try:
779
- await opencode_supervisor.prune_idle()
780
- except Exception as exc:
781
- safe_log(
782
- app.state.logger,
783
- logging.WARNING,
784
- "OpenCode prune task failed",
785
- exc,
786
- )
787
- except asyncio.CancelledError:
788
- return
789
-
790
- tasks.append(asyncio.create_task(_opencode_prune_loop()))
791
-
792
- pr_flow_manager = getattr(app.state, "pr_flow_manager", None)
793
- if pr_flow_manager is None:
794
- pr_flow_manager = PrFlowManager(
795
- app.state.engine.repo_root,
796
- app_server_supervisor=getattr(app.state, "app_server_supervisor", None),
797
- opencode_supervisor=getattr(app.state, "opencode_supervisor", None),
798
- logger=getattr(app.state, "logger", None),
799
- )
800
- app.state.pr_flow_manager = pr_flow_manager
801
- chatops = GitHubChatOpsPoller(
802
- app.state.engine.repo_root,
803
- pr_flow_manager,
804
- logger=getattr(app.state, "logger", None),
805
- )
806
- app.state.pr_flow_chatops = chatops
807
- if pr_flow_manager.chatops_config().get("enabled", False):
808
- tasks.append(asyncio.create_task(chatops.run()))
809
-
810
- if (
811
- context.tui_idle_seconds is not None
812
- and context.tui_idle_check_seconds is not None
813
- ):
814
-
815
- async def _tui_idle_loop():
816
- try:
817
- while True:
818
- await asyncio.sleep(context.tui_idle_check_seconds)
819
- try:
820
- async with app.state.terminal_lock:
821
- terminal_sessions = app.state.terminal_sessions
822
- session_registry = app.state.session_registry
823
- for session_id, session in list(
824
- terminal_sessions.items()
825
- ):
826
- if not session.pty.isalive():
827
- continue
828
- if not session.should_notify_idle(
829
- context.tui_idle_seconds
830
- ):
831
- continue
832
- record = session_registry.get(session_id)
833
- repo_path = record.repo_path if record else None
834
- notifier = getattr(
835
- app.state.engine, "notifier", None
836
- )
837
- if notifier:
838
- asyncio.create_task(
839
- notifier.notify_tui_idle_async(
840
- session_id=session_id,
841
- idle_seconds=context.tui_idle_seconds,
842
- repo_path=repo_path,
843
- )
844
- )
845
- except Exception as exc:
846
- safe_log(
847
- app.state.logger,
848
- logging.WARNING,
849
- "TUI idle notification loop failed",
850
- exc,
851
- )
852
- except asyncio.CancelledError:
853
- return
854
-
855
- tasks.append(asyncio.create_task(_tui_idle_loop()))
856
-
857
- # Shutdown event for graceful SSE/WebSocket termination during reload
858
- app.state.shutdown_event = asyncio.Event()
859
- app.state.active_websockets: set = set()
860
-
861
- try:
862
- yield
863
- finally:
864
- # Signal SSE streams to stop and close WebSocket connections
865
- app.state.shutdown_event.set()
866
- for ws in list(app.state.active_websockets):
867
- try:
868
- await ws.close(code=1012) # 1012 = Service Restart
869
- except Exception as exc:
870
- safe_log(
871
- app.state.logger,
872
- logging.DEBUG,
873
- "Failed to close websocket during shutdown",
874
- exc=exc,
875
- )
876
- app.state.active_websockets.clear()
877
-
878
- for task in tasks:
879
- task.cancel()
880
- if tasks:
881
- await asyncio.gather(*tasks, return_exceptions=True)
882
- chatops = getattr(app.state, "pr_flow_chatops", None)
883
- if chatops is not None:
884
- try:
885
- await chatops.stop()
886
- except Exception as exc:
887
- safe_log(
888
- app.state.logger,
889
- logging.DEBUG,
890
- "Failed to stop chatops during shutdown",
891
- exc=exc,
892
- )
893
- async with app.state.terminal_lock:
894
- for session in app.state.terminal_sessions.values():
895
- session.close()
896
- app.state.terminal_sessions.clear()
897
- app.state.session_registry.clear()
898
- app.state.repo_to_session.clear()
899
- persist_session_registry(
900
- app.state.engine.state_path,
901
- app.state.session_registry,
902
- app.state.repo_to_session,
903
- )
904
- app_server_supervisor = getattr(app.state, "app_server_supervisor", None)
905
- if app_server_supervisor is not None:
906
- try:
907
- await app_server_supervisor.close_all()
908
- except Exception as exc:
909
- safe_log(
910
- app.state.logger,
911
- logging.WARNING,
912
- "App-server shutdown failed",
913
- exc,
914
- )
915
- opencode_supervisor = getattr(app.state, "opencode_supervisor", None)
916
- if opencode_supervisor is not None:
917
- try:
918
- await opencode_supervisor.close_all()
919
- except Exception as exc:
920
- safe_log(
921
- app.state.logger,
922
- logging.WARNING,
923
- "OpenCode shutdown failed",
924
- exc,
925
- )
926
- static_context = getattr(app.state, "static_assets_context", None)
927
- if static_context is not None:
928
- static_context.close()
929
-
930
- return lifespan
931
-
932
-
933
- def create_app(
934
- repo_root: Optional[Path] = None,
935
- base_path: Optional[str] = None,
936
- server_overrides: Optional[ServerOverrides] = None,
937
- hub_config: Optional[HubConfig] = None,
938
- ) -> ASGIApp:
939
- context = _build_app_context(repo_root, base_path, hub_config=hub_config)
940
- app = FastAPI(redirect_slashes=False, lifespan=_app_lifespan(context))
941
- _apply_app_context(app, context)
942
- app.add_middleware(GZipMiddleware, minimum_size=500)
943
- static_files = CacheStaticFiles(directory=context.static_dir)
944
- app.state.static_files = static_files
945
- app.state.static_assets_lock = threading.Lock()
946
- app.state.hub_static_assets = (
947
- hub_config.static_assets if hub_config is not None else None
948
- )
949
- app.mount("/static", static_files, name="static")
950
- # Route handlers
951
- app.include_router(build_repo_router(context.static_dir))
952
-
953
- allowed_hosts = _resolve_allowed_hosts(
954
- context.engine.config.server_host, context.engine.config.server_allowed_hosts
955
- )
956
- allowed_origins = context.engine.config.server_allowed_origins
957
- auth_token_env = context.engine.config.server_auth_token_env
958
- if server_overrides is not None:
959
- if server_overrides.allowed_hosts is not None:
960
- allowed_hosts = list(server_overrides.allowed_hosts)
961
- if server_overrides.allowed_origins is not None:
962
- allowed_origins = list(server_overrides.allowed_origins)
963
- if server_overrides.auth_token_env is not None:
964
- auth_token_env = server_overrides.auth_token_env
965
- auth_token = _resolve_auth_token(auth_token_env, env=context.env)
966
- app.state.auth_token = auth_token
967
- asgi_app: ASGIApp = app
968
- if auth_token:
969
- asgi_app = AuthTokenMiddleware(asgi_app, auth_token, context.base_path)
970
- if context.base_path:
971
- asgi_app = BasePathRouterMiddleware(asgi_app, context.base_path)
972
- asgi_app = HostOriginMiddleware(asgi_app, allowed_hosts, allowed_origins)
973
- asgi_app = RequestIdMiddleware(asgi_app)
974
- asgi_app = SecurityHeadersMiddleware(asgi_app)
975
-
976
- return asgi_app
977
-
978
-
979
- def create_hub_app(
980
- hub_root: Optional[Path] = None, base_path: Optional[str] = None
981
- ) -> ASGIApp:
982
- context = _build_hub_context(hub_root, base_path)
983
- app = FastAPI(redirect_slashes=False)
984
- _apply_hub_context(app, context)
985
- app.add_middleware(GZipMiddleware, minimum_size=500)
986
- static_files = CacheStaticFiles(directory=context.static_dir)
987
- app.state.static_files = static_files
988
- app.state.static_assets_lock = threading.Lock()
989
- app.state.hub_static_assets = None
990
- app.mount("/static", static_files, name="static")
991
- mounted_repos: set[str] = set()
992
- mount_errors: dict[str, str] = {}
993
- repo_apps: dict[str, ASGIApp] = {}
994
- repo_lifespans: dict[str, object] = {}
995
- mount_order: list[str] = []
996
- mount_lock: Optional[asyncio.Lock] = None
997
-
998
- async def _get_mount_lock() -> asyncio.Lock:
999
- nonlocal mount_lock
1000
- if mount_lock is None:
1001
- mount_lock = asyncio.Lock()
1002
- return mount_lock
1003
-
1004
- app.state.hub_started = False
1005
- repo_server_overrides: Optional[ServerOverrides] = None
1006
- if context.config.repo_server_inherit:
1007
- repo_server_overrides = ServerOverrides(
1008
- allowed_hosts=_resolve_allowed_hosts(
1009
- context.config.server_host, context.config.server_allowed_hosts
1010
- ),
1011
- allowed_origins=list(context.config.server_allowed_origins),
1012
- auth_token_env=context.config.server_auth_token_env,
1013
- )
1014
-
1015
- def _unwrap_fastapi(sub_app: ASGIApp) -> Optional[FastAPI]:
1016
- current: ASGIApp = sub_app
1017
- while not isinstance(current, FastAPI):
1018
- nested = getattr(current, "app", None)
1019
- if nested is None:
1020
- return None
1021
- current = nested
1022
- return current
1023
-
1024
- async def _start_repo_lifespan_locked(prefix: str, sub_app: ASGIApp) -> None:
1025
- if prefix in repo_lifespans:
1026
- return
1027
- fastapi_app = _unwrap_fastapi(sub_app)
1028
- if fastapi_app is None:
1029
- return
1030
- try:
1031
- ctx = fastapi_app.router.lifespan_context(fastapi_app)
1032
- await ctx.__aenter__()
1033
- repo_lifespans[prefix] = ctx
1034
- safe_log(
1035
- app.state.logger,
1036
- logging.INFO,
1037
- f"Repo app lifespan entered for {prefix}",
1038
- )
1039
- except Exception as exc:
1040
- mount_errors[prefix] = str(exc)
1041
- try:
1042
- app.state.logger.warning("Repo lifespan failed for %s: %s", prefix, exc)
1043
- except Exception as exc2:
1044
- safe_log(
1045
- app.state.logger,
1046
- logging.DEBUG,
1047
- f"Failed to log repo lifespan failure for {prefix}",
1048
- exc=exc2,
1049
- )
1050
- await _unmount_repo_locked(prefix)
1051
-
1052
- async def _stop_repo_lifespan_locked(prefix: str) -> None:
1053
- ctx = repo_lifespans.pop(prefix, None)
1054
- if ctx is None:
1055
- return
1056
- try:
1057
- await ctx.__aexit__(None, None, None)
1058
- safe_log(
1059
- app.state.logger,
1060
- logging.INFO,
1061
- f"Repo app lifespan exited for {prefix}",
1062
- )
1063
- except Exception as exc:
1064
- try:
1065
- app.state.logger.warning(
1066
- "Repo lifespan shutdown failed for %s: %s", prefix, exc
1067
- )
1068
- except Exception as exc2:
1069
- safe_log(
1070
- app.state.logger,
1071
- logging.DEBUG,
1072
- f"Failed to log repo lifespan shutdown failure for {prefix}",
1073
- exc=exc2,
1074
- )
1075
-
1076
- def _detach_mount_locked(prefix: str) -> None:
1077
- mount_path = f"/repos/{prefix}"
1078
- app.router.routes = [
1079
- route
1080
- for route in app.router.routes
1081
- if not (isinstance(route, Mount) and route.path == mount_path)
1082
- ]
1083
- mounted_repos.discard(prefix)
1084
- repo_apps.pop(prefix, None)
1085
- if prefix in mount_order:
1086
- mount_order.remove(prefix)
1087
-
1088
- async def _unmount_repo_locked(prefix: str) -> None:
1089
- await _stop_repo_lifespan_locked(prefix)
1090
- _detach_mount_locked(prefix)
1091
-
1092
- def _mount_repo_sync(prefix: str, repo_path: Path) -> bool:
1093
- if prefix in mounted_repos:
1094
- return True
1095
- if prefix in mount_errors:
1096
- return False
1097
- try:
1098
- # Hub already handles the base path; avoid reapplying it in child apps.
1099
- sub_app = create_app(
1100
- repo_path,
1101
- base_path="",
1102
- server_overrides=repo_server_overrides,
1103
- hub_config=context.config,
1104
- )
1105
- except ConfigError as exc:
1106
- mount_errors[prefix] = str(exc)
1107
- try:
1108
- app.state.logger.warning("Cannot mount repo %s: %s", prefix, exc)
1109
- except Exception as exc2:
1110
- safe_log(
1111
- app.state.logger,
1112
- logging.DEBUG,
1113
- f"Failed to log mount error for {prefix}",
1114
- exc=exc2,
1115
- )
1116
- return False
1117
- except Exception as exc:
1118
- mount_errors[prefix] = str(exc)
1119
- try:
1120
- app.state.logger.warning("Cannot mount repo %s: %s", prefix, exc)
1121
- except Exception as exc2:
1122
- safe_log(
1123
- app.state.logger,
1124
- logging.DEBUG,
1125
- f"Failed to log mount error for {prefix}",
1126
- exc=exc2,
1127
- )
1128
- return False
1129
- app.mount(f"/repos/{prefix}", sub_app)
1130
- mounted_repos.add(prefix)
1131
- repo_apps[prefix] = sub_app
1132
- if prefix not in mount_order:
1133
- mount_order.append(prefix)
1134
- mount_errors.pop(prefix, None)
1135
- return True
1136
-
1137
- async def _refresh_mounts(snapshots, *, full_refresh: bool = True):
1138
- desired = {
1139
- snap.id for snap in snapshots if snap.initialized and snap.exists_on_disk
1140
- }
1141
- mount_lock = await _get_mount_lock()
1142
- async with mount_lock:
1143
- if full_refresh:
1144
- for prefix in list(mounted_repos):
1145
- if prefix not in desired:
1146
- await _unmount_repo_locked(prefix)
1147
- for prefix in list(mount_errors):
1148
- if prefix not in desired:
1149
- mount_errors.pop(prefix, None)
1150
- for snap in snapshots:
1151
- if snap.id not in desired:
1152
- continue
1153
- if snap.id in mounted_repos or snap.id in mount_errors:
1154
- continue
1155
- # Hub already handles the base path; avoid reapplying it in child apps.
1156
- try:
1157
- sub_app = create_app(
1158
- snap.path,
1159
- base_path="",
1160
- server_overrides=repo_server_overrides,
1161
- hub_config=context.config,
1162
- )
1163
- except ConfigError as exc:
1164
- mount_errors[snap.id] = str(exc)
1165
- try:
1166
- app.state.logger.warning(
1167
- "Cannot mount repo %s: %s", snap.id, exc
1168
- )
1169
- except Exception as exc2:
1170
- safe_log(
1171
- app.state.logger,
1172
- logging.DEBUG,
1173
- f"Failed to log mount error for snapshot {snap.id}",
1174
- exc=exc2,
1175
- )
1176
- continue
1177
- except Exception as exc:
1178
- mount_errors[snap.id] = str(exc)
1179
- try:
1180
- app.state.logger.warning(
1181
- "Cannot mount repo %s: %s", snap.id, exc
1182
- )
1183
- except Exception as exc2:
1184
- safe_log(
1185
- app.state.logger,
1186
- logging.DEBUG,
1187
- f"Failed to log mount error for snapshot {snap.id}",
1188
- exc=exc2,
1189
- )
1190
- continue
1191
- app.mount(f"/repos/{snap.id}", sub_app)
1192
- mounted_repos.add(snap.id)
1193
- repo_apps[snap.id] = sub_app
1194
- if snap.id not in mount_order:
1195
- mount_order.append(snap.id)
1196
- mount_errors.pop(snap.id, None)
1197
- if app.state.hub_started:
1198
- await _start_repo_lifespan_locked(snap.id, sub_app)
1199
-
1200
- def _add_mount_info(repo_dict: dict) -> dict:
1201
- """Add mount_status to repo dict for UI to know if navigation is possible."""
1202
- repo_id = repo_dict.get("id")
1203
- if repo_id in mount_errors:
1204
- repo_dict["mounted"] = False
1205
- repo_dict["mount_error"] = mount_errors[repo_id]
1206
- elif repo_id in mounted_repos:
1207
- repo_dict["mounted"] = True
1208
- else:
1209
- repo_dict["mounted"] = False
1210
- return repo_dict
1211
-
1212
- initial_snapshots = context.supervisor.scan()
1213
- for snap in initial_snapshots:
1214
- if snap.initialized and snap.exists_on_disk:
1215
- _mount_repo_sync(snap.id, snap.path)
1216
-
1217
- @asynccontextmanager
1218
- async def lifespan(app: FastAPI):
1219
- app.state.hub_started = True
1220
- if app.state.config.housekeeping.enabled:
1221
- interval = max(app.state.config.housekeeping.interval_seconds, 1)
1222
-
1223
- async def _housekeeping_loop():
1224
- while True:
1225
- try:
1226
- await asyncio.to_thread(
1227
- run_housekeeping_once,
1228
- app.state.config.housekeeping,
1229
- app.state.config.root,
1230
- logger=app.state.logger,
1231
- )
1232
- except Exception as exc:
1233
- safe_log(
1234
- app.state.logger,
1235
- logging.WARNING,
1236
- "Housekeeping task failed",
1237
- exc,
1238
- )
1239
- await asyncio.sleep(interval)
1240
-
1241
- asyncio.create_task(_housekeeping_loop())
1242
- app_server_supervisor = getattr(app.state, "app_server_supervisor", None)
1243
- app_server_prune_interval = getattr(
1244
- app.state, "app_server_prune_interval", None
1245
- )
1246
- if app_server_supervisor is not None and app_server_prune_interval:
1247
-
1248
- async def _app_server_prune_loop():
1249
- while True:
1250
- await asyncio.sleep(app_server_prune_interval)
1251
- try:
1252
- await app_server_supervisor.prune_idle()
1253
- except Exception as exc:
1254
- safe_log(
1255
- app.state.logger,
1256
- logging.WARNING,
1257
- "Hub app-server prune task failed",
1258
- exc,
1259
- )
1260
-
1261
- asyncio.create_task(_app_server_prune_loop())
1262
- mount_lock = await _get_mount_lock()
1263
- async with mount_lock:
1264
- for prefix in list(mount_order):
1265
- sub_app = repo_apps.get(prefix)
1266
- if sub_app is not None:
1267
- await _start_repo_lifespan_locked(prefix, sub_app)
1268
- try:
1269
- yield
1270
- finally:
1271
- mount_lock = await _get_mount_lock()
1272
- async with mount_lock:
1273
- for prefix in list(reversed(mount_order)):
1274
- await _stop_repo_lifespan_locked(prefix)
1275
- for prefix in list(mounted_repos):
1276
- _detach_mount_locked(prefix)
1277
- app_server_supervisor = getattr(app.state, "app_server_supervisor", None)
1278
- if app_server_supervisor is not None:
1279
- try:
1280
- await app_server_supervisor.close_all()
1281
- except Exception as exc:
1282
- safe_log(
1283
- app.state.logger,
1284
- logging.WARNING,
1285
- "Hub app-server shutdown failed",
1286
- exc,
1287
- )
1288
- static_context = getattr(app.state, "static_assets_context", None)
1289
- if static_context is not None:
1290
- static_context.close()
1291
-
1292
- app.router.lifespan_context = lifespan
1293
-
1294
- @app.get("/hub/usage")
1295
- def hub_usage(since: Optional[str] = None, until: Optional[str] = None):
1296
- try:
1297
- since_dt = parse_iso_datetime(since)
1298
- until_dt = parse_iso_datetime(until)
1299
- except UsageError as exc:
1300
- raise HTTPException(status_code=400, detail=str(exc)) from exc
1301
-
1302
- manifest = load_manifest(context.config.manifest_path, context.config.root)
1303
- repo_map = [
1304
- (repo.id, (context.config.root / repo.path)) for repo in manifest.repos
1305
- ]
1306
- per_repo, unmatched, status = get_hub_usage_summary_cached(
1307
- repo_map,
1308
- default_codex_home(),
1309
- since=since_dt,
1310
- until=until_dt,
1311
- )
1312
- return {
1313
- "mode": "hub",
1314
- "hub_root": str(context.config.root),
1315
- "codex_home": str(default_codex_home()),
1316
- "since": since,
1317
- "until": until,
1318
- "status": status,
1319
- "repos": [
1320
- {
1321
- "id": repo_id,
1322
- "events": summary.events,
1323
- "totals": summary.totals.to_dict(),
1324
- "latest_rate_limits": summary.latest_rate_limits,
1325
- }
1326
- for repo_id, summary in per_repo.items()
1327
- ],
1328
- "unmatched": unmatched.to_dict(),
1329
- }
1330
-
1331
- @app.get("/hub/usage/series")
1332
- def hub_usage_series(
1333
- since: Optional[str] = None,
1334
- until: Optional[str] = None,
1335
- bucket: str = "day",
1336
- segment: str = "none",
1337
- ):
1338
- try:
1339
- since_dt = parse_iso_datetime(since)
1340
- until_dt = parse_iso_datetime(until)
1341
- except UsageError as exc:
1342
- raise HTTPException(status_code=400, detail=str(exc)) from exc
1343
-
1344
- manifest = load_manifest(context.config.manifest_path, context.config.root)
1345
- repo_map = [
1346
- (repo.id, (context.config.root / repo.path)) for repo in manifest.repos
1347
- ]
1348
- try:
1349
- series, status = get_hub_usage_series_cached(
1350
- repo_map,
1351
- default_codex_home(),
1352
- since=since_dt,
1353
- until=until_dt,
1354
- bucket=bucket,
1355
- segment=segment,
1356
- )
1357
- except UsageError as exc:
1358
- raise HTTPException(status_code=400, detail=str(exc)) from exc
1359
- return {
1360
- "mode": "hub",
1361
- "hub_root": str(context.config.root),
1362
- "codex_home": str(default_codex_home()),
1363
- "since": since,
1364
- "until": until,
1365
- "status": status,
1366
- **series,
1367
- }
1368
-
1369
- @app.get("/hub/repos")
1370
- async def list_repos():
1371
- safe_log(app.state.logger, logging.INFO, "Hub list_repos")
1372
- snapshots = await asyncio.to_thread(context.supervisor.list_repos)
1373
- await _refresh_mounts(snapshots)
1374
- return {
1375
- "last_scan_at": context.supervisor.state.last_scan_at,
1376
- "repos": [
1377
- _add_mount_info(repo.to_dict(context.config.root)) for repo in snapshots
1378
- ],
1379
- }
1380
-
1381
- @app.get("/hub/version")
1382
- def hub_version():
1383
- return {"asset_version": app.state.asset_version}
1384
-
1385
- @app.post("/hub/repos/scan")
1386
- async def scan_repos():
1387
- safe_log(app.state.logger, logging.INFO, "Hub scan_repos")
1388
- snapshots = await asyncio.to_thread(context.supervisor.scan)
1389
- await _refresh_mounts(snapshots)
1390
- return {
1391
- "last_scan_at": context.supervisor.state.last_scan_at,
1392
- "repos": [
1393
- _add_mount_info(repo.to_dict(context.config.root)) for repo in snapshots
1394
- ],
1395
- }
1396
-
1397
- @app.post("/hub/jobs/scan", response_model=HubJobResponse)
1398
- async def scan_repos_job():
1399
- async def _run_scan():
1400
- snapshots = await asyncio.to_thread(context.supervisor.scan)
1401
- await _refresh_mounts(snapshots)
1402
- return {"status": "ok"}
1403
-
1404
- job = await context.job_manager.submit(
1405
- "hub.scan_repos", _run_scan, request_id=get_request_id()
1406
- )
1407
- return job.to_dict()
1408
-
1409
- @app.post("/hub/repos")
1410
- async def create_repo(payload: HubCreateRepoRequest):
1411
- git_url = payload.git_url
1412
- repo_id = payload.repo_id
1413
- if not repo_id and not git_url:
1414
- raise HTTPException(status_code=400, detail="Missing repo id")
1415
- repo_path_val = payload.path
1416
- repo_path = Path(repo_path_val) if repo_path_val else None
1417
- git_init = payload.git_init
1418
- force = payload.force
1419
- safe_log(
1420
- app.state.logger,
1421
- logging.INFO,
1422
- "Hub create repo id=%s path=%s git_init=%s force=%s git_url=%s"
1423
- % (repo_id, repo_path_val, git_init, force, bool(git_url)),
1424
- )
1425
- try:
1426
- if git_url:
1427
- snapshot = await asyncio.to_thread(
1428
- context.supervisor.clone_repo,
1429
- git_url=str(git_url),
1430
- repo_id=str(repo_id) if repo_id else None,
1431
- repo_path=repo_path,
1432
- force=force,
1433
- )
1434
- else:
1435
- snapshot = await asyncio.to_thread(
1436
- context.supervisor.create_repo,
1437
- str(repo_id),
1438
- repo_path=repo_path,
1439
- git_init=git_init,
1440
- force=force,
1441
- )
1442
- except Exception as exc:
1443
- raise HTTPException(status_code=400, detail=str(exc)) from exc
1444
- await _refresh_mounts([snapshot], full_refresh=False)
1445
- return _add_mount_info(snapshot.to_dict(context.config.root))
1446
-
1447
- @app.post("/hub/jobs/repos", response_model=HubJobResponse)
1448
- async def create_repo_job(payload: HubCreateRepoRequest):
1449
- async def _run_create_repo():
1450
- git_url = payload.git_url
1451
- repo_id = payload.repo_id
1452
- if not repo_id and not git_url:
1453
- raise ValueError("Missing repo id")
1454
- repo_path_val = payload.path
1455
- repo_path = Path(repo_path_val) if repo_path_val else None
1456
- git_init = payload.git_init
1457
- force = payload.force
1458
- if git_url:
1459
- snapshot = await asyncio.to_thread(
1460
- context.supervisor.clone_repo,
1461
- git_url=str(git_url),
1462
- repo_id=str(repo_id) if repo_id else None,
1463
- repo_path=repo_path,
1464
- force=force,
1465
- )
1466
- else:
1467
- snapshot = await asyncio.to_thread(
1468
- context.supervisor.create_repo,
1469
- str(repo_id),
1470
- repo_path=repo_path,
1471
- git_init=git_init,
1472
- force=force,
1473
- )
1474
- await _refresh_mounts([snapshot], full_refresh=False)
1475
- return _add_mount_info(snapshot.to_dict(context.config.root))
1476
-
1477
- job = await context.job_manager.submit(
1478
- "hub.create_repo", _run_create_repo, request_id=get_request_id()
1479
- )
1480
- return job.to_dict()
1481
-
1482
- @app.get("/hub/repos/{repo_id}/remove-check")
1483
- async def remove_repo_check(repo_id: str):
1484
- safe_log(app.state.logger, logging.INFO, f"Hub remove-check {repo_id}")
1485
- try:
1486
- return await asyncio.to_thread(
1487
- context.supervisor.check_repo_removal, repo_id
1488
- )
1489
- except Exception as exc:
1490
- raise HTTPException(status_code=400, detail=str(exc)) from exc
1491
-
1492
- @app.post("/hub/repos/{repo_id}/remove")
1493
- async def remove_repo(repo_id: str, payload: Optional[HubRemoveRepoRequest] = None):
1494
- payload = payload or HubRemoveRepoRequest()
1495
- force = payload.force
1496
- delete_dir = payload.delete_dir
1497
- delete_worktrees = payload.delete_worktrees
1498
- safe_log(
1499
- app.state.logger,
1500
- logging.INFO,
1501
- "Hub remove repo id=%s force=%s delete_dir=%s delete_worktrees=%s"
1502
- % (repo_id, force, delete_dir, delete_worktrees),
1503
- )
1504
- try:
1505
- await asyncio.to_thread(
1506
- context.supervisor.remove_repo,
1507
- repo_id,
1508
- force=force,
1509
- delete_dir=delete_dir,
1510
- delete_worktrees=delete_worktrees,
1511
- )
1512
- except Exception as exc:
1513
- raise HTTPException(status_code=400, detail=str(exc)) from exc
1514
- snapshots = await asyncio.to_thread(
1515
- context.supervisor.list_repos, use_cache=False
1516
- )
1517
- await _refresh_mounts(snapshots)
1518
- return {"status": "ok"}
1519
-
1520
- @app.post("/hub/jobs/repos/{repo_id}/remove", response_model=HubJobResponse)
1521
- async def remove_repo_job(
1522
- repo_id: str, payload: Optional[HubRemoveRepoRequest] = None
1523
- ):
1524
- payload = payload or HubRemoveRepoRequest()
1525
-
1526
- async def _run_remove_repo():
1527
- await asyncio.to_thread(
1528
- context.supervisor.remove_repo,
1529
- repo_id,
1530
- force=payload.force,
1531
- delete_dir=payload.delete_dir,
1532
- delete_worktrees=payload.delete_worktrees,
1533
- )
1534
- snapshots = await asyncio.to_thread(
1535
- context.supervisor.list_repos, use_cache=False
1536
- )
1537
- await _refresh_mounts(snapshots)
1538
- return {"status": "ok"}
1539
-
1540
- job = await context.job_manager.submit(
1541
- "hub.remove_repo", _run_remove_repo, request_id=get_request_id()
1542
- )
1543
- return job.to_dict()
1544
-
1545
- @app.post("/hub/worktrees/create")
1546
- async def create_worktree(payload: HubCreateWorktreeRequest):
1547
- base_repo_id = payload.base_repo_id
1548
- branch = payload.branch
1549
- force = payload.force
1550
- start_point = payload.start_point
1551
- safe_log(
1552
- app.state.logger,
1553
- logging.INFO,
1554
- "Hub create worktree base=%s branch=%s force=%s start_point=%s"
1555
- % (base_repo_id, branch, force, start_point),
1556
- )
1557
- try:
1558
- snapshot = await asyncio.to_thread(
1559
- context.supervisor.create_worktree,
1560
- base_repo_id=str(base_repo_id),
1561
- branch=str(branch),
1562
- force=force,
1563
- start_point=str(start_point) if start_point else None,
1564
- )
1565
- except Exception as exc:
1566
- raise HTTPException(status_code=400, detail=str(exc)) from exc
1567
- await _refresh_mounts([snapshot], full_refresh=False)
1568
- return _add_mount_info(snapshot.to_dict(context.config.root))
1569
-
1570
- @app.post("/hub/jobs/worktrees/create", response_model=HubJobResponse)
1571
- async def create_worktree_job(payload: HubCreateWorktreeRequest):
1572
- async def _run_create_worktree():
1573
- snapshot = await asyncio.to_thread(
1574
- context.supervisor.create_worktree,
1575
- base_repo_id=str(payload.base_repo_id),
1576
- branch=str(payload.branch),
1577
- force=payload.force,
1578
- start_point=str(payload.start_point) if payload.start_point else None,
1579
- )
1580
- await _refresh_mounts([snapshot], full_refresh=False)
1581
- return _add_mount_info(snapshot.to_dict(context.config.root))
1582
-
1583
- job = await context.job_manager.submit(
1584
- "hub.create_worktree", _run_create_worktree, request_id=get_request_id()
1585
- )
1586
- return job.to_dict()
1587
-
1588
- @app.post("/hub/worktrees/cleanup")
1589
- async def cleanup_worktree(payload: HubCleanupWorktreeRequest):
1590
- worktree_repo_id = payload.worktree_repo_id
1591
- delete_branch = payload.delete_branch
1592
- delete_remote = payload.delete_remote
1593
- safe_log(
1594
- app.state.logger,
1595
- logging.INFO,
1596
- "Hub cleanup worktree id=%s delete_branch=%s delete_remote=%s"
1597
- % (worktree_repo_id, delete_branch, delete_remote),
1598
- )
1599
- try:
1600
- await asyncio.to_thread(
1601
- context.supervisor.cleanup_worktree,
1602
- worktree_repo_id=str(worktree_repo_id),
1603
- delete_branch=delete_branch,
1604
- delete_remote=delete_remote,
1605
- )
1606
- except Exception as exc:
1607
- raise HTTPException(status_code=400, detail=str(exc)) from exc
1608
- return {"status": "ok"}
1609
-
1610
- @app.post("/hub/jobs/worktrees/cleanup", response_model=HubJobResponse)
1611
- async def cleanup_worktree_job(payload: HubCleanupWorktreeRequest):
1612
- def _run_cleanup_worktree():
1613
- context.supervisor.cleanup_worktree(
1614
- worktree_repo_id=str(payload.worktree_repo_id),
1615
- delete_branch=payload.delete_branch,
1616
- delete_remote=payload.delete_remote,
1617
- )
1618
- return {"status": "ok"}
1619
-
1620
- job = await context.job_manager.submit(
1621
- "hub.cleanup_worktree", _run_cleanup_worktree, request_id=get_request_id()
1622
- )
1623
- return job.to_dict()
1624
-
1625
- @app.get("/hub/jobs/{job_id}", response_model=HubJobResponse)
1626
- async def get_hub_job(job_id: str):
1627
- job = await context.job_manager.get(job_id)
1628
- if not job:
1629
- raise HTTPException(status_code=404, detail="Job not found")
1630
- return job.to_dict()
1631
-
1632
- @app.post("/hub/repos/{repo_id}/run")
1633
- async def run_repo(repo_id: str, payload: Optional[RunControlRequest] = None):
1634
- once = payload.once if payload else False
1635
- safe_log(
1636
- app.state.logger,
1637
- logging.INFO,
1638
- "Hub run %s once=%s" % (repo_id, once),
1639
- )
1640
- try:
1641
- snapshot = await asyncio.to_thread(
1642
- context.supervisor.run_repo, repo_id, once=once
1643
- )
1644
- except LockError as exc:
1645
- raise HTTPException(status_code=409, detail=str(exc)) from exc
1646
- except Exception as exc:
1647
- raise HTTPException(status_code=400, detail=str(exc)) from exc
1648
- await _refresh_mounts([snapshot], full_refresh=False)
1649
- return _add_mount_info(snapshot.to_dict(context.config.root))
1650
-
1651
- @app.post("/hub/repos/{repo_id}/stop")
1652
- async def stop_repo(repo_id: str):
1653
- safe_log(app.state.logger, logging.INFO, f"Hub stop {repo_id}")
1654
- try:
1655
- snapshot = await asyncio.to_thread(context.supervisor.stop_repo, repo_id)
1656
- except Exception as exc:
1657
- raise HTTPException(status_code=400, detail=str(exc)) from exc
1658
- return _add_mount_info(snapshot.to_dict(context.config.root))
1659
-
1660
- @app.post("/hub/repos/{repo_id}/resume")
1661
- async def resume_repo(repo_id: str, payload: Optional[RunControlRequest] = None):
1662
- once = payload.once if payload else False
1663
- safe_log(
1664
- app.state.logger,
1665
- logging.INFO,
1666
- "Hub resume %s once=%s" % (repo_id, once),
1667
- )
1668
- try:
1669
- snapshot = await asyncio.to_thread(
1670
- context.supervisor.resume_repo, repo_id, once=once
1671
- )
1672
- except LockError as exc:
1673
- raise HTTPException(status_code=409, detail=str(exc)) from exc
1674
- except Exception as exc:
1675
- raise HTTPException(status_code=400, detail=str(exc)) from exc
1676
- await _refresh_mounts([snapshot], full_refresh=False)
1677
- return _add_mount_info(snapshot.to_dict(context.config.root))
1678
-
1679
- @app.post("/hub/repos/{repo_id}/kill")
1680
- async def kill_repo(repo_id: str):
1681
- safe_log(app.state.logger, logging.INFO, f"Hub kill {repo_id}")
1682
- try:
1683
- snapshot = await asyncio.to_thread(context.supervisor.kill_repo, repo_id)
1684
- except Exception as exc:
1685
- raise HTTPException(status_code=400, detail=str(exc)) from exc
1686
- return _add_mount_info(snapshot.to_dict(context.config.root))
1687
-
1688
- @app.post("/hub/repos/{repo_id}/init")
1689
- async def init_repo(repo_id: str):
1690
- safe_log(app.state.logger, logging.INFO, f"Hub init {repo_id}")
1691
- try:
1692
- snapshot = await asyncio.to_thread(context.supervisor.init_repo, repo_id)
1693
- except Exception as exc:
1694
- raise HTTPException(status_code=400, detail=str(exc)) from exc
1695
- await _refresh_mounts([snapshot], full_refresh=False)
1696
- return _add_mount_info(snapshot.to_dict(context.config.root))
1697
-
1698
- @app.post("/hub/repos/{repo_id}/sync-main")
1699
- async def sync_repo_main(repo_id: str):
1700
- safe_log(app.state.logger, logging.INFO, f"Hub sync main {repo_id}")
1701
- try:
1702
- snapshot = await asyncio.to_thread(context.supervisor.sync_main, repo_id)
1703
- except Exception as exc:
1704
- raise HTTPException(status_code=400, detail=str(exc)) from exc
1705
- await _refresh_mounts([snapshot], full_refresh=False)
1706
- return _add_mount_info(snapshot.to_dict(context.config.root))
1707
-
1708
- @app.get("/", include_in_schema=False)
1709
- def hub_index():
1710
- index_path = context.static_dir / "index.html"
1711
- if not index_path.exists():
1712
- raise HTTPException(
1713
- status_code=500, detail="Static UI assets missing; reinstall package"
1714
- )
1715
- html = render_index_html(context.static_dir, app.state.asset_version)
1716
- return HTMLResponse(html, headers=index_response_headers())
1717
-
1718
- app.include_router(build_system_routes())
1719
-
1720
- allowed_hosts = _resolve_allowed_hosts(
1721
- context.config.server_host, context.config.server_allowed_hosts
1722
- )
1723
- allowed_origins = context.config.server_allowed_origins
1724
- auth_token = _resolve_auth_token(context.config.server_auth_token_env)
1725
- app.state.auth_token = auth_token
1726
- asgi_app: ASGIApp = app
1727
- if auth_token:
1728
- asgi_app = AuthTokenMiddleware(asgi_app, auth_token, context.base_path)
1729
- if context.base_path:
1730
- asgi_app = BasePathRouterMiddleware(asgi_app, context.base_path)
1731
- asgi_app = HostOriginMiddleware(asgi_app, allowed_hosts, allowed_origins)
1732
- asgi_app = RequestIdMiddleware(asgi_app)
1733
- asgi_app = SecurityHeadersMiddleware(asgi_app)
1734
-
1735
- return asgi_app
1736
-
1737
-
1738
- def _resolve_auth_token(
1739
- env_name: str, *, env: Optional[Mapping[str, str]] = None
1740
- ) -> Optional[str]:
1741
- if not env_name:
1742
- return None
1743
- source = env if env is not None else os.environ
1744
- value = source.get(env_name)
1745
- if value is None:
1746
- return None
1747
- value = value.strip()
1748
- return value or None
1749
-
1750
-
1751
- def _resolve_allowed_hosts(host: str, allowed_hosts: list[str]) -> list[str]:
1752
- cleaned = [entry.strip() for entry in allowed_hosts if entry and entry.strip()]
1753
- if cleaned:
1754
- return cleaned
1755
- if _is_loopback_host(host):
1756
- return ["localhost", "127.0.0.1", "::1", "testserver"]
1757
- return []
1758
-
1759
-
1760
- _STATIC_CACHE_CONTROL = "public, max-age=31536000, immutable"
1761
-
1762
-
1763
- class CacheStaticFiles(StaticFiles):
1764
- def __init__(self, *args, cache_control: str = _STATIC_CACHE_CONTROL, **kwargs):
1765
- super().__init__(*args, **kwargs)
1766
- self._cache_control = cache_control
1767
-
1768
- async def get_response(self, path: str, scope): # type: ignore[override]
1769
- response = await super().get_response(path, scope)
1770
- if response.status_code in (200, 206, 304):
1771
- response.headers.setdefault("Cache-Control", self._cache_control)
1772
- return response
3
+ from ..surfaces.web.app import * # noqa: F401,F403