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