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,280 @@
1
+ """
2
+ Shared utilities for route modules.
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from ....core.locks import (
12
+ DEFAULT_RUNNER_CMD_HINTS,
13
+ assess_lock,
14
+ process_is_active,
15
+ read_lock_info,
16
+ )
17
+ from ....core.state import load_state
18
+ from ....core.utils import (
19
+ apply_codex_options,
20
+ extract_flag_value,
21
+ resolve_opencode_binary,
22
+ supports_reasoning,
23
+ )
24
+
25
+ BYPASS_FLAGS = {
26
+ "--yolo",
27
+ "--dangerously-bypass-approvals-and-sandbox",
28
+ }
29
+
30
+ SSE_HEADERS = {
31
+ "Cache-Control": "no-cache",
32
+ "X-Accel-Buffering": "no",
33
+ "Connection": "keep-alive",
34
+ "Content-Encoding": "identity",
35
+ }
36
+
37
+
38
+ async def _interruptible_sleep(
39
+ seconds: float, shutdown_event: Optional[asyncio.Event]
40
+ ) -> bool:
41
+ """Sleep that can be interrupted by shutdown_event. Returns True if interrupted."""
42
+ if shutdown_event is None:
43
+ await asyncio.sleep(seconds)
44
+ return False
45
+ try:
46
+ await asyncio.wait_for(shutdown_event.wait(), timeout=seconds)
47
+ return True # Event was set
48
+ except asyncio.TimeoutError:
49
+ return False # Normal timeout, continue
50
+
51
+
52
+ def _extract_bypass_flag(args: list[str]) -> tuple[str, list[str]]:
53
+ chosen = None
54
+ for arg in args:
55
+ if arg in BYPASS_FLAGS:
56
+ chosen = arg
57
+ break
58
+ filtered = [arg for arg in args if arg not in BYPASS_FLAGS]
59
+ return chosen or "--yolo", filtered
60
+
61
+
62
+ def build_codex_terminal_cmd(
63
+ engine,
64
+ *,
65
+ resume_mode: bool,
66
+ model: Optional[str] = None,
67
+ reasoning: Optional[str] = None,
68
+ ) -> list[str]:
69
+ """
70
+ Build the subprocess argv for launching the Codex interactive CLI inside a PTY.
71
+ """
72
+ bypass_flag, terminal_args = _extract_bypass_flag(
73
+ list(engine.config.codex_terminal_args)
74
+ )
75
+ if resume_mode:
76
+ cmd = [
77
+ engine.config.codex_binary,
78
+ bypass_flag,
79
+ "resume",
80
+ *terminal_args,
81
+ ]
82
+ return apply_codex_options(
83
+ cmd,
84
+ model=model,
85
+ reasoning=reasoning,
86
+ supports_reasoning=supports_reasoning(engine.config.codex_binary),
87
+ )
88
+
89
+ cmd = [
90
+ engine.config.codex_binary,
91
+ bypass_flag,
92
+ *terminal_args,
93
+ ]
94
+ return apply_codex_options(
95
+ cmd,
96
+ model=model,
97
+ reasoning=reasoning,
98
+ supports_reasoning=supports_reasoning(engine.config.codex_binary),
99
+ )
100
+
101
+
102
+ def build_opencode_terminal_cmd(binary: str, model: Optional[str] = None) -> list[str]:
103
+ resolved = resolve_opencode_binary(binary)
104
+ cmd = [resolved or binary]
105
+ if model:
106
+ cmd.extend(["--model", model])
107
+ return cmd
108
+
109
+
110
+ def resolve_runner_status(engine, state) -> tuple[str, Optional[int], bool]:
111
+ pid = state.runner_pid
112
+ alive_pid = pid if pid and process_is_active(pid) else None
113
+ if alive_pid is None:
114
+ info = read_lock_info(engine.lock_path)
115
+ if info.pid and process_is_active(info.pid):
116
+ alive_pid = info.pid
117
+ running = alive_pid is not None
118
+ status = state.status
119
+ if status == "running" and not running:
120
+ status = "idle"
121
+ runner_pid = alive_pid if running else None
122
+ return status, runner_pid, running
123
+
124
+
125
+ def resolve_lock_payload(engine) -> dict[str, object]:
126
+ assessment = assess_lock(
127
+ engine.lock_path,
128
+ expected_cmd_substrings=DEFAULT_RUNNER_CMD_HINTS,
129
+ )
130
+ return {
131
+ "lock_present": engine.lock_path.exists(),
132
+ "lock_pid": assessment.pid,
133
+ "lock_freeable": assessment.freeable,
134
+ "lock_freeable_reason": assessment.reason,
135
+ }
136
+
137
+
138
+ async def log_stream(
139
+ log_path: Path,
140
+ heartbeat_interval: float = 15.0,
141
+ shutdown_event: Optional[asyncio.Event] = None,
142
+ max_seconds: float = 60.0,
143
+ ):
144
+ """SSE stream generator for log file tailing."""
145
+ if not log_path.exists():
146
+ yield "data: log file not found\n\n"
147
+ return
148
+ last_emit_at = time.monotonic()
149
+ start_time = time.monotonic()
150
+ with log_path.open("r", encoding="utf-8") as f:
151
+ f.seek(0, 2)
152
+ while True:
153
+ if shutdown_event is not None and shutdown_event.is_set():
154
+ return
155
+ if time.monotonic() - start_time > max_seconds:
156
+ yield "event: timeout\ndata: Stream timeout exceeded\n\n"
157
+ return
158
+ line = f.readline()
159
+ if line:
160
+ yield f"data: {line.rstrip()}\n\n"
161
+ last_emit_at = time.monotonic()
162
+ else:
163
+ now = time.monotonic()
164
+ if now - last_emit_at >= heartbeat_interval:
165
+ yield ": ping\n\n"
166
+ last_emit_at = now
167
+ if await _interruptible_sleep(0.5, shutdown_event):
168
+ return
169
+
170
+
171
+ async def jsonl_event_stream(
172
+ path: Path,
173
+ *,
174
+ event_name: str = "message",
175
+ heartbeat_interval: float = 15.0,
176
+ shutdown_event: Optional[asyncio.Event] = None,
177
+ ):
178
+ """SSE stream generator for JSONL event files."""
179
+ last_emit_at = time.monotonic()
180
+ position = 0
181
+ while True:
182
+ if shutdown_event is not None and shutdown_event.is_set():
183
+ return
184
+ if not path.exists():
185
+ now = time.monotonic()
186
+ if now - last_emit_at >= heartbeat_interval:
187
+ yield ": ping\n\n"
188
+ last_emit_at = now
189
+ if await _interruptible_sleep(1.0, shutdown_event):
190
+ return
191
+ continue
192
+ try:
193
+ with path.open("r", encoding="utf-8") as handle:
194
+ handle.seek(position)
195
+ while True:
196
+ if shutdown_event is not None and shutdown_event.is_set():
197
+ return
198
+ line = handle.readline()
199
+ if line:
200
+ position = handle.tell()
201
+ payload = line.strip()
202
+ if payload:
203
+ yield f"event: {event_name}\ndata: {payload}\n\n"
204
+ last_emit_at = time.monotonic()
205
+ else:
206
+ now = time.monotonic()
207
+ if now - last_emit_at >= heartbeat_interval:
208
+ yield ": ping\n\n"
209
+ last_emit_at = now
210
+ if await _interruptible_sleep(0.5, shutdown_event):
211
+ return
212
+ except OSError:
213
+ if await _interruptible_sleep(1.0, shutdown_event):
214
+ return
215
+
216
+
217
+ async def state_stream(
218
+ engine,
219
+ manager,
220
+ logger=None,
221
+ heartbeat_interval: float = 15.0,
222
+ shutdown_event: Optional[asyncio.Event] = None,
223
+ max_seconds: float = 60.0,
224
+ ):
225
+ """SSE stream generator for state updates."""
226
+ last_payload = None
227
+ last_error_log_at = 0.0
228
+ last_emit_at = time.monotonic()
229
+ start_time = time.monotonic()
230
+ terminal_idle_timeout_seconds = engine.config.terminal_idle_timeout_seconds
231
+ codex_model = engine.config.codex_model or extract_flag_value(
232
+ engine.config.codex_args, "--model"
233
+ )
234
+ while True:
235
+ if shutdown_event is not None and shutdown_event.is_set():
236
+ return
237
+ if time.monotonic() - start_time > max_seconds:
238
+ yield "event: timeout\ndata: Stream timeout exceeded\n\n"
239
+ return
240
+ emitted = False
241
+ try:
242
+ state = await asyncio.to_thread(load_state, engine.state_path)
243
+ outstanding, done = await asyncio.to_thread(engine.docs.todos)
244
+ status, runner_pid, running = resolve_runner_status(engine, state)
245
+ lock_payload = resolve_lock_payload(engine)
246
+ payload = {
247
+ "last_run_id": state.last_run_id,
248
+ "status": status,
249
+ "last_exit_code": state.last_exit_code,
250
+ "last_run_started_at": state.last_run_started_at,
251
+ "last_run_finished_at": state.last_run_finished_at,
252
+ "outstanding_count": len(outstanding),
253
+ "done_count": len(done),
254
+ "running": running,
255
+ "runner_pid": runner_pid,
256
+ **lock_payload,
257
+ "terminal_idle_timeout_seconds": terminal_idle_timeout_seconds,
258
+ "codex_model": codex_model or "auto",
259
+ }
260
+ if payload != last_payload:
261
+ yield f"data: {json.dumps(payload)}\n\n"
262
+ last_payload = payload
263
+ last_emit_at = time.monotonic()
264
+ emitted = True
265
+ except Exception:
266
+ # Don't spam logs, but don't swallow silently either.
267
+ now = time.time()
268
+ if logger is not None and (now - last_error_log_at) > 60:
269
+ last_error_log_at = now
270
+ try:
271
+ logger.warning("state stream error", exc_info=True)
272
+ except Exception:
273
+ pass
274
+ if not emitted:
275
+ now = time.monotonic()
276
+ if now - last_emit_at >= heartbeat_interval:
277
+ yield ": ping\n\n"
278
+ last_emit_at = now
279
+ if await _interruptible_sleep(1.0, shutdown_event):
280
+ return
@@ -0,0 +1,196 @@
1
+ import asyncio
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from fastapi import APIRouter, HTTPException, Request
7
+ from fastapi.responses import JSONResponse
8
+
9
+ from ....core import update as update_core
10
+ from ....core.config import HubConfig
11
+ from ....core.update import (
12
+ UpdateInProgressError,
13
+ _normalize_update_ref,
14
+ _normalize_update_target,
15
+ _read_update_status,
16
+ _spawn_update_process,
17
+ _system_update_check,
18
+ )
19
+ from ....core.update_paths import resolve_update_paths
20
+ from ..schemas import (
21
+ SystemHealthResponse,
22
+ SystemUpdateCheckResponse,
23
+ SystemUpdateRequest,
24
+ SystemUpdateResponse,
25
+ SystemUpdateStatusResponse,
26
+ )
27
+ from ..static_assets import missing_static_assets
28
+ from ..static_refresh import refresh_static_assets
29
+
30
+ _pid_is_running = update_core._pid_is_running
31
+ _system_update_worker = update_core._system_update_worker
32
+ _update_lock_active = update_core._update_lock_active
33
+ _update_lock_path = update_core._update_lock_path
34
+ _update_status_path = update_core._update_status_path
35
+ shutil = update_core.shutil
36
+ subprocess = update_core.subprocess
37
+
38
+
39
+ def build_system_routes() -> APIRouter:
40
+ router = APIRouter()
41
+
42
+ @router.get("/health", response_model=SystemHealthResponse)
43
+ async def system_health(request: Request):
44
+ try:
45
+ config = request.app.state.config
46
+ except AttributeError:
47
+ config = None
48
+ mode = "hub" if isinstance(config, HubConfig) else "repo"
49
+ base_path = getattr(request.app.state, "base_path", "")
50
+ asset_version = getattr(request.app.state, "asset_version", None)
51
+ static_dir = getattr(getattr(request.app, "state", None), "static_dir", None)
52
+ if not isinstance(static_dir, Path):
53
+ return JSONResponse(
54
+ {
55
+ "status": "error",
56
+ "detail": "Static UI assets missing; reinstall package",
57
+ "mode": mode,
58
+ "base_path": base_path,
59
+ },
60
+ status_code=500,
61
+ )
62
+ missing = await asyncio.to_thread(missing_static_assets, static_dir)
63
+ if missing:
64
+ if refresh_static_assets(request.app):
65
+ static_dir = getattr(
66
+ getattr(request.app, "state", None), "static_dir", None
67
+ )
68
+ if isinstance(static_dir, Path):
69
+ missing = await asyncio.to_thread(missing_static_assets, static_dir)
70
+ else:
71
+ missing = ["index.html"]
72
+ if not missing:
73
+ return {
74
+ "status": "ok",
75
+ "mode": mode,
76
+ "base_path": base_path,
77
+ "asset_version": asset_version,
78
+ }
79
+ return JSONResponse(
80
+ {
81
+ "status": "error",
82
+ "detail": "Static UI assets missing; reinstall package",
83
+ "missing": missing,
84
+ "mode": mode,
85
+ "base_path": base_path,
86
+ },
87
+ status_code=500,
88
+ )
89
+ return {
90
+ "status": "ok",
91
+ "mode": mode,
92
+ "base_path": base_path,
93
+ "asset_version": asset_version,
94
+ }
95
+
96
+ @router.get("/system/update/check", response_model=SystemUpdateCheckResponse)
97
+ async def system_update_check(request: Request):
98
+ """
99
+ Check if an update is available by comparing local git state vs remote.
100
+ If local git state is unavailable, report that an update may be available.
101
+ """
102
+ try:
103
+ config = request.app.state.config
104
+ except AttributeError:
105
+ config = None
106
+
107
+ repo_url = "https://github.com/Git-on-my-level/codex-autorunner.git"
108
+ repo_ref = "main"
109
+ if config and isinstance(config, HubConfig):
110
+ configured_url = getattr(config, "update_repo_url", None)
111
+ if configured_url:
112
+ repo_url = configured_url
113
+ configured_ref = getattr(config, "update_repo_ref", None)
114
+ if configured_ref:
115
+ repo_ref = configured_ref
116
+
117
+ try:
118
+ return await asyncio.to_thread(
119
+ _system_update_check, repo_url=repo_url, repo_ref=repo_ref
120
+ )
121
+ except Exception as e:
122
+ logger = getattr(getattr(request.app, "state", None), "logger", None)
123
+ if logger:
124
+ logger.error("Update check error: %s", e, exc_info=True)
125
+ raise HTTPException(status_code=500, detail=str(e)) from e
126
+
127
+ @router.post("/system/update", response_model=SystemUpdateResponse)
128
+ async def system_update(
129
+ request: Request, payload: Optional[SystemUpdateRequest] = None
130
+ ):
131
+ """
132
+ Pull latest code and refresh the running service.
133
+ This will restart the server if successful.
134
+ """
135
+ try:
136
+ config = request.app.state.config
137
+ except AttributeError:
138
+ config = None
139
+
140
+ # Determine URL
141
+ repo_url = "https://github.com/Git-on-my-level/codex-autorunner.git"
142
+ repo_ref = "main"
143
+ skip_checks = False
144
+ if config and isinstance(config, HubConfig):
145
+ configured_url = getattr(config, "update_repo_url", None)
146
+ if configured_url:
147
+ repo_url = configured_url
148
+ configured_ref = getattr(config, "update_repo_ref", None)
149
+ if configured_ref:
150
+ repo_ref = configured_ref
151
+ skip_checks = bool(getattr(config, "update_skip_checks", False))
152
+ elif config is not None:
153
+ skip_checks = bool(getattr(config, "update_skip_checks", False))
154
+
155
+ update_dir = resolve_update_paths(config=config).cache_dir
156
+
157
+ try:
158
+ target_raw = payload.target if payload else None
159
+ if target_raw is None:
160
+ target_raw = request.query_params.get("target")
161
+ update_target = _normalize_update_target(target_raw)
162
+ logger = getattr(getattr(request.app, "state", None), "logger", None)
163
+ if logger is None:
164
+ logger = logging.getLogger("codex_autorunner.system_update")
165
+ await asyncio.to_thread(
166
+ _spawn_update_process,
167
+ repo_url=repo_url,
168
+ repo_ref=_normalize_update_ref(repo_ref),
169
+ update_dir=update_dir,
170
+ logger=logger,
171
+ update_target=update_target,
172
+ skip_checks=skip_checks,
173
+ )
174
+ return {
175
+ "status": "ok",
176
+ "message": f"Update started ({update_target}). Service will restart shortly.",
177
+ "target": update_target,
178
+ }
179
+ except UpdateInProgressError as exc:
180
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
181
+ except ValueError as exc:
182
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
183
+ except Exception as e:
184
+ logger = getattr(getattr(request.app, "state", None), "logger", None)
185
+ if logger:
186
+ logger.error("Update error: %s", e, exc_info=True)
187
+ raise HTTPException(status_code=500, detail=str(e)) from e
188
+
189
+ @router.get("/system/update/status", response_model=SystemUpdateStatusResponse)
190
+ async def system_update_status():
191
+ status = await asyncio.to_thread(_read_update_status)
192
+ if status is None:
193
+ return {"status": "unknown", "message": "No update status recorded."}
194
+ return status
195
+
196
+ return router
@@ -0,0 +1,89 @@
1
+ """
2
+ Usage routes: token usage summaries for repo/hub.
3
+
4
+ Moved out of the legacy docs routes during the workspace + file chat cutover.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ from fastapi import APIRouter, HTTPException, Request
12
+
13
+ from ....core.usage import (
14
+ UsageError,
15
+ default_codex_home,
16
+ get_repo_usage_series_cached,
17
+ get_repo_usage_summary_cached,
18
+ parse_iso_datetime,
19
+ )
20
+ from ..schemas import RepoUsageResponse, UsageSeriesResponse
21
+
22
+
23
+ def build_usage_routes() -> APIRouter:
24
+ router = APIRouter(prefix="/api", tags=["usage"])
25
+
26
+ @router.get("/usage", response_model=RepoUsageResponse)
27
+ def get_usage(
28
+ request: Request, since: Optional[str] = None, until: Optional[str] = None
29
+ ):
30
+ engine = request.app.state.engine
31
+ try:
32
+ since_dt = parse_iso_datetime(since)
33
+ until_dt = parse_iso_datetime(until)
34
+ except UsageError as exc:
35
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
36
+ summary, status = get_repo_usage_summary_cached(
37
+ engine.repo_root,
38
+ default_codex_home(),
39
+ config=engine.config,
40
+ since=since_dt,
41
+ until=until_dt,
42
+ )
43
+ return {
44
+ "mode": "repo",
45
+ "repo": str(engine.repo_root),
46
+ "codex_home": str(default_codex_home()),
47
+ "since": since,
48
+ "until": until,
49
+ "status": status,
50
+ **summary.to_dict(),
51
+ }
52
+
53
+ @router.get("/usage/series", response_model=UsageSeriesResponse)
54
+ def get_usage_series(
55
+ request: Request,
56
+ since: Optional[str] = None,
57
+ until: Optional[str] = None,
58
+ bucket: str = "day",
59
+ segment: str = "none",
60
+ ):
61
+ engine = request.app.state.engine
62
+ try:
63
+ since_dt = parse_iso_datetime(since)
64
+ until_dt = parse_iso_datetime(until)
65
+ except UsageError as exc:
66
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
67
+ try:
68
+ series, status = get_repo_usage_series_cached(
69
+ engine.repo_root,
70
+ default_codex_home(),
71
+ config=engine.config,
72
+ since=since_dt,
73
+ until=until_dt,
74
+ bucket=bucket,
75
+ segment=segment,
76
+ )
77
+ except UsageError as exc:
78
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
79
+ return {
80
+ "mode": "repo",
81
+ "repo": str(engine.repo_root),
82
+ "codex_home": str(default_codex_home()),
83
+ "since": since,
84
+ "until": until,
85
+ "status": status,
86
+ **series,
87
+ }
88
+
89
+ return router
@@ -0,0 +1,120 @@
1
+ """
2
+ Voice transcription and configuration routes.
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import Optional
8
+
9
+ from fastapi import APIRouter, File, HTTPException, Request, UploadFile
10
+
11
+ from ....voice import VoiceService, VoiceServiceError
12
+
13
+ logger = logging.getLogger("codex_autorunner.routes.voice")
14
+
15
+
16
+ def build_voice_routes() -> APIRouter:
17
+ """Build routes for voice transcription and config."""
18
+ router = APIRouter()
19
+
20
+ @router.get("/api/voice/config")
21
+ def get_voice_config(request: Request):
22
+ voice_service: Optional[VoiceService] = request.app.state.voice_service
23
+ voice_config = request.app.state.voice_config
24
+ missing_reason = getattr(request.app.state, "voice_missing_reason", None)
25
+ if missing_reason:
26
+ return {
27
+ "enabled": False,
28
+ "provider": voice_config.provider,
29
+ "latency_mode": voice_config.latency_mode,
30
+ "chunk_ms": voice_config.chunk_ms,
31
+ "sample_rate": voice_config.sample_rate,
32
+ "warn_on_remote_api": voice_config.warn_on_remote_api,
33
+ "has_api_key": False,
34
+ "push_to_talk": {
35
+ "max_ms": voice_config.push_to_talk.max_ms,
36
+ "silence_auto_stop_ms": voice_config.push_to_talk.silence_auto_stop_ms,
37
+ "min_hold_ms": voice_config.push_to_talk.min_hold_ms,
38
+ },
39
+ "missing_extra": missing_reason,
40
+ }
41
+ if voice_service is None:
42
+ # Degrade gracefully: still return config to the UI even if service init failed.
43
+ try:
44
+ return VoiceService(
45
+ voice_config, logger=request.app.state.logger
46
+ ).config_payload()
47
+ except (ValueError, TypeError, OSError) as exc:
48
+ logger.debug("Failed to create VoiceService for config: %s", exc)
49
+ return {
50
+ "enabled": False,
51
+ "provider": voice_config.provider,
52
+ "latency_mode": voice_config.latency_mode,
53
+ "chunk_ms": voice_config.chunk_ms,
54
+ "sample_rate": voice_config.sample_rate,
55
+ "warn_on_remote_api": voice_config.warn_on_remote_api,
56
+ "has_api_key": False,
57
+ "push_to_talk": {
58
+ "max_ms": voice_config.push_to_talk.max_ms,
59
+ "silence_auto_stop_ms": voice_config.push_to_talk.silence_auto_stop_ms,
60
+ "min_hold_ms": voice_config.push_to_talk.min_hold_ms,
61
+ },
62
+ }
63
+ return voice_service.config_payload()
64
+
65
+ @router.post("/api/voice/transcribe")
66
+ async def transcribe_voice(
67
+ request: Request,
68
+ file: Optional[UploadFile] = File(None),
69
+ language: Optional[str] = None,
70
+ ):
71
+ voice_service: Optional[VoiceService] = request.app.state.voice_service
72
+ voice_config = request.app.state.voice_config
73
+ missing_reason = getattr(request.app.state, "voice_missing_reason", None)
74
+ if missing_reason:
75
+ raise HTTPException(status_code=503, detail=missing_reason)
76
+ if not voice_service or not voice_config.enabled:
77
+ raise HTTPException(status_code=400, detail="Voice is disabled")
78
+
79
+ filename: Optional[str] = None
80
+ content_type: Optional[str] = None
81
+ if file is not None:
82
+ filename = file.filename
83
+ content_type = file.content_type
84
+ try:
85
+ audio_bytes = await file.read()
86
+ except Exception as exc:
87
+ raise HTTPException(
88
+ status_code=400, detail="Unable to read audio upload"
89
+ ) from exc
90
+ else:
91
+ audio_bytes = await request.body()
92
+ try:
93
+ result = await asyncio.to_thread(
94
+ voice_service.transcribe,
95
+ audio_bytes,
96
+ client="web",
97
+ user_agent=request.headers.get("user-agent"),
98
+ language=language,
99
+ filename=filename,
100
+ content_type=content_type,
101
+ )
102
+ except VoiceServiceError as exc:
103
+ if exc.reason == "unauthorized":
104
+ status = 401
105
+ elif exc.reason == "forbidden":
106
+ status = 403
107
+ elif exc.reason == "audio_too_large":
108
+ status = 413
109
+ elif exc.reason == "rate_limited":
110
+ status = 429
111
+ else:
112
+ status = (
113
+ 400
114
+ if exc.reason in ("disabled", "empty_audio", "invalid_audio")
115
+ else 502
116
+ )
117
+ raise HTTPException(status_code=status, detail=exc.detail) from exc
118
+ return {"status": "ok", **result}
119
+
120
+ return router