codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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 (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,197 @@
1
+ """
2
+ Repository run control routes: start, stop, resume, reset, kill.
3
+ """
4
+
5
+ from typing import Optional
6
+
7
+ from fastapi import APIRouter, HTTPException, Request
8
+
9
+ from ....core.runtime import LockError, clear_stale_lock
10
+ from ....core.state import RunnerState, load_state, now_iso, save_state, state_lock
11
+ from ..schemas import (
12
+ RunControlRequest,
13
+ RunControlResponse,
14
+ RunResetResponse,
15
+ RunStatusResponse,
16
+ )
17
+
18
+
19
+ def _normalize_override(value: Optional[str]) -> Optional[str]:
20
+ if not isinstance(value, str):
21
+ return None
22
+ trimmed = value.strip()
23
+ return trimmed or None
24
+
25
+
26
+ def _apply_run_overrides(request: Request, payload: RunControlRequest) -> None:
27
+ engine = request.app.state.engine
28
+ agent = _normalize_override(payload.agent)
29
+ model = _normalize_override(payload.model)
30
+ reasoning = _normalize_override(payload.reasoning)
31
+ fields_set = getattr(payload, "model_fields_set", set())
32
+ agent_set = "agent" in fields_set
33
+ model_set = "model" in fields_set
34
+ reasoning_set = "reasoning" in fields_set
35
+ if not (agent_set or model_set or reasoning_set):
36
+ return
37
+ with state_lock(engine.state_path):
38
+ state = load_state(engine.state_path)
39
+ new_state = RunnerState(
40
+ last_run_id=state.last_run_id,
41
+ status=state.status,
42
+ last_exit_code=state.last_exit_code,
43
+ last_run_started_at=state.last_run_started_at,
44
+ last_run_finished_at=state.last_run_finished_at,
45
+ autorunner_agent_override=(
46
+ agent if agent_set else state.autorunner_agent_override
47
+ ),
48
+ autorunner_model_override=(
49
+ model if model_set else state.autorunner_model_override
50
+ ),
51
+ autorunner_effort_override=(
52
+ reasoning if reasoning_set else state.autorunner_effort_override
53
+ ),
54
+ autorunner_approval_policy=state.autorunner_approval_policy,
55
+ autorunner_sandbox_mode=state.autorunner_sandbox_mode,
56
+ autorunner_workspace_write_network=state.autorunner_workspace_write_network,
57
+ runner_pid=state.runner_pid,
58
+ sessions=state.sessions,
59
+ repo_to_session=state.repo_to_session,
60
+ )
61
+ save_state(engine.state_path, new_state)
62
+
63
+
64
+ def build_repos_routes() -> APIRouter:
65
+ """Build routes for run control."""
66
+ router = APIRouter()
67
+
68
+ @router.post("/api/run/start", response_model=RunControlResponse)
69
+ def start_run(request: Request, payload: Optional[RunControlRequest] = None):
70
+ manager = request.app.state.manager
71
+ logger = request.app.state.logger
72
+ once = payload.once if payload else False
73
+ try:
74
+ logger.info("run/start once=%s", once)
75
+ except Exception:
76
+ pass
77
+ if payload:
78
+ _apply_run_overrides(request, payload)
79
+ try:
80
+ manager.start(once=once)
81
+ except LockError as exc:
82
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
83
+ return {"running": manager.running, "once": once}
84
+
85
+ @router.post("/api/run/stop", response_model=RunStatusResponse)
86
+ def stop_run(request: Request):
87
+ manager = request.app.state.manager
88
+ logger = request.app.state.logger
89
+ try:
90
+ logger.info("run/stop requested")
91
+ except Exception:
92
+ pass
93
+ manager.stop()
94
+ return {"running": manager.running}
95
+
96
+ @router.post("/api/run/kill", response_model=RunStatusResponse)
97
+ def kill_run(request: Request):
98
+ engine = request.app.state.engine
99
+ manager = request.app.state.manager
100
+ logger = request.app.state.logger
101
+ try:
102
+ logger.info("run/kill requested")
103
+ except Exception:
104
+ pass
105
+ manager.kill()
106
+ with state_lock(engine.state_path):
107
+ state = load_state(engine.state_path)
108
+ new_state = RunnerState(
109
+ last_run_id=state.last_run_id,
110
+ status="error",
111
+ last_exit_code=137,
112
+ last_run_started_at=state.last_run_started_at,
113
+ last_run_finished_at=now_iso(),
114
+ autorunner_agent_override=state.autorunner_agent_override,
115
+ autorunner_model_override=state.autorunner_model_override,
116
+ autorunner_effort_override=state.autorunner_effort_override,
117
+ autorunner_approval_policy=state.autorunner_approval_policy,
118
+ autorunner_sandbox_mode=state.autorunner_sandbox_mode,
119
+ autorunner_workspace_write_network=state.autorunner_workspace_write_network,
120
+ runner_pid=None,
121
+ sessions=state.sessions,
122
+ repo_to_session=state.repo_to_session,
123
+ )
124
+ save_state(engine.state_path, new_state)
125
+ clear_stale_lock(engine.lock_path)
126
+ engine.reconcile_run_index()
127
+ return {"running": manager.running}
128
+
129
+ @router.post("/api/run/clear-lock", response_model=RunStatusResponse)
130
+ def clear_lock(request: Request):
131
+ manager = request.app.state.manager
132
+ logger = request.app.state.logger
133
+ try:
134
+ logger.info("run/clear-lock requested")
135
+ except Exception:
136
+ pass
137
+ assessment = manager.clear_freeable_lock()
138
+ if not assessment.freeable:
139
+ detail = "Lock is still active; cannot clear."
140
+ if assessment.pid:
141
+ detail = f"Lock pid {assessment.pid} is still active; cannot clear."
142
+ raise HTTPException(status_code=409, detail=detail)
143
+ return {"running": manager.running}
144
+
145
+ @router.post("/api/run/resume", response_model=RunControlResponse)
146
+ def resume_run(request: Request, payload: Optional[RunControlRequest] = None):
147
+ manager = request.app.state.manager
148
+ logger = request.app.state.logger
149
+ once = payload.once if payload else False
150
+ try:
151
+ logger.info("run/resume once=%s", once)
152
+ except Exception:
153
+ pass
154
+ try:
155
+ manager.resume(once=once)
156
+ except LockError as exc:
157
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
158
+ return {"running": manager.running, "once": once}
159
+
160
+ @router.post("/api/run/reset", response_model=RunResetResponse)
161
+ def reset_runner(request: Request):
162
+ engine = request.app.state.engine
163
+ manager = request.app.state.manager
164
+ logger = request.app.state.logger
165
+ if manager.running:
166
+ raise HTTPException(
167
+ status_code=409, detail="Cannot reset while runner is active"
168
+ )
169
+ try:
170
+ logger.info("run/reset requested")
171
+ except Exception:
172
+ pass
173
+ with state_lock(engine.state_path):
174
+ current_state = load_state(engine.state_path)
175
+ engine.lock_path.unlink(missing_ok=True)
176
+ initial_state = RunnerState(
177
+ last_run_id=None,
178
+ status="idle",
179
+ last_exit_code=None,
180
+ last_run_started_at=None,
181
+ last_run_finished_at=None,
182
+ autorunner_agent_override=current_state.autorunner_agent_override,
183
+ autorunner_model_override=current_state.autorunner_model_override,
184
+ autorunner_effort_override=current_state.autorunner_effort_override,
185
+ autorunner_approval_policy=current_state.autorunner_approval_policy,
186
+ autorunner_sandbox_mode=current_state.autorunner_sandbox_mode,
187
+ autorunner_workspace_write_network=current_state.autorunner_workspace_write_network,
188
+ runner_pid=None,
189
+ sessions=current_state.sessions,
190
+ repo_to_session=current_state.repo_to_session,
191
+ )
192
+ save_state(engine.state_path, initial_state)
193
+ if engine.log_path.exists():
194
+ engine.log_path.unlink()
195
+ return {"status": "ok", "message": "Runner reset complete"}
196
+
197
+ return router
@@ -0,0 +1,148 @@
1
+ """
2
+ Review workflow routes.
3
+ """
4
+
5
+ from pathlib import Path
6
+
7
+ from fastapi import APIRouter, HTTPException, Query, Request
8
+ from fastapi.responses import FileResponse
9
+
10
+ from ..review import ReviewBusyError, ReviewError, ReviewService
11
+ from ..schemas import (
12
+ ReviewControlResponse,
13
+ ReviewStartRequest,
14
+ ReviewStatusResponse,
15
+ )
16
+
17
+
18
+ def _review(request: Request) -> ReviewService:
19
+ """Get a ReviewService instance from request."""
20
+ manager = getattr(request.app.state, "review_manager", None)
21
+ if manager is None:
22
+ engine = request.app.state.engine
23
+ manager = ReviewService(
24
+ engine,
25
+ app_server_supervisor=getattr(
26
+ request.app.state, "app_server_supervisor", None
27
+ ),
28
+ opencode_supervisor=getattr(request.app.state, "opencode_supervisor", None),
29
+ logger=getattr(request.app.state, "logger", None),
30
+ )
31
+ request.app.state.review_manager = manager
32
+ return manager
33
+
34
+
35
+ def build_review_routes() -> APIRouter:
36
+ """Build routes for review workflow."""
37
+ router = APIRouter()
38
+
39
+ @router.get("/api/review/status")
40
+ async def review_status(request: Request):
41
+ try:
42
+ service = _review(request)
43
+ status = service.status()
44
+ return ReviewStatusResponse(review=status)
45
+ except ReviewError as exc:
46
+ raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
47
+ except Exception as exc:
48
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
49
+
50
+ @router.post("/api/review/start")
51
+ async def review_start(request: Request, payload: ReviewStartRequest):
52
+ try:
53
+ service = _review(request)
54
+ state = service.start(payload=payload.model_dump(exclude_none=True))
55
+ return ReviewControlResponse(
56
+ status=state.get("status", "unknown"),
57
+ detail="Review started",
58
+ )
59
+ except ReviewBusyError as exc:
60
+ raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
61
+ except ReviewError as exc:
62
+ raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
63
+ except Exception as exc:
64
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
65
+
66
+ @router.post("/api/review/stop")
67
+ async def review_stop(request: Request):
68
+ try:
69
+ service = _review(request)
70
+ state = service.stop()
71
+ return ReviewControlResponse(
72
+ status=state.get("status", "unknown"),
73
+ detail="Review stopped",
74
+ )
75
+ except ReviewError as exc:
76
+ raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
77
+ except Exception as exc:
78
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
79
+
80
+ @router.post("/api/review/reset")
81
+ async def review_reset(request: Request):
82
+ try:
83
+ service = _review(request)
84
+ state = service.reset()
85
+ return ReviewControlResponse(
86
+ status=state.get("status", "idle"),
87
+ detail="Review state reset",
88
+ )
89
+ except ReviewBusyError as exc:
90
+ raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
91
+ except ReviewError as exc:
92
+ raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
93
+ except Exception as exc:
94
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
95
+
96
+ @router.get("/api/review/artifact")
97
+ async def review_artifact(
98
+ request: Request,
99
+ kind: str = Query(
100
+ ..., description="final_report|workflow_log|scratchpad_bundle"
101
+ ),
102
+ ):
103
+ try:
104
+ service = _review(request)
105
+ status = service.status()
106
+
107
+ mapping = {
108
+ "final_report": status.get("final_output_path"),
109
+ "workflow_log": status.get("run_dir"),
110
+ "scratchpad_bundle": status.get("scratchpad_bundle_path"),
111
+ }
112
+
113
+ raw_path = mapping.get(kind)
114
+ if not raw_path:
115
+ raise HTTPException(status_code=404, detail="Artifact not found")
116
+
117
+ target = Path(raw_path).expanduser().resolve()
118
+ allowed_root = request.app.state.engine.repo_root.resolve()
119
+
120
+ try:
121
+ target.relative_to(allowed_root)
122
+ if ".codex-autorunner" not in target.parts:
123
+ raise HTTPException(status_code=403, detail="Access denied")
124
+ except ValueError:
125
+ raise HTTPException(status_code=403, detail="Access denied") from None
126
+
127
+ if not target.exists():
128
+ raise HTTPException(status_code=404, detail="Artifact not found")
129
+
130
+ if kind == "workflow_log" and target.is_dir():
131
+ target = target / "review.log"
132
+
133
+ media_type = "text/plain"
134
+ if target.suffix == ".md":
135
+ media_type = "text/markdown"
136
+ elif target.suffix == ".zip":
137
+ media_type = "application/zip"
138
+
139
+ return FileResponse(target, media_type=media_type, filename=target.name)
140
+
141
+ except ReviewError as exc:
142
+ raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
143
+ except HTTPException:
144
+ raise
145
+ except Exception as exc:
146
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
147
+
148
+ return router
@@ -0,0 +1,176 @@
1
+ """
2
+ Terminal session registry routes.
3
+ """
4
+
5
+ import logging
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from fastapi import APIRouter, HTTPException, Request
10
+
11
+ from ....core.state import persist_session_registry
12
+ from ..schemas import (
13
+ SessionsResponse,
14
+ SessionStopRequest,
15
+ SessionStopResponse,
16
+ )
17
+
18
+ logger = logging.getLogger("codex_autorunner.routes.sessions")
19
+
20
+
21
+ def _relative_repo_path(repo_path: str, repo_root: Path) -> str:
22
+ path = Path(repo_path)
23
+ if not path.is_absolute():
24
+ return repo_path
25
+ try:
26
+ rel = path.resolve().relative_to(repo_root)
27
+ return rel.as_posix() or "."
28
+ except ValueError as exc:
29
+ logger.debug("Failed to resolve relative path: %s", exc)
30
+ return path.name
31
+
32
+
33
+ def _relative_repo_key(repo_key: str, repo_root: Path) -> str:
34
+ """
35
+ Format repo_to_session keys for display in API responses.
36
+
37
+ Keys are either:
38
+ - `<repo_path>` for the default codex agent (backwards compatible)
39
+ - `<repo_path>:<agent>` for non-default agents (e.g. opencode)
40
+ """
41
+ if ":" not in repo_key:
42
+ return _relative_repo_path(repo_key, repo_root)
43
+ repo_path, agent = repo_key.split(":", 1)
44
+ rel = _relative_repo_path(repo_path, repo_root)
45
+ agent = agent.strip().lower()
46
+ if not agent or agent == "codex":
47
+ return rel
48
+ return f"{rel}:{agent}"
49
+
50
+
51
+ def _allow_abs_paths(request: Request, include_abs_paths: bool) -> bool:
52
+ if not include_abs_paths:
53
+ return False
54
+ return bool(getattr(request.app.state, "auth_token", None))
55
+
56
+
57
+ def _session_payload(
58
+ session_id: str,
59
+ record,
60
+ terminal_sessions: dict,
61
+ repo_root: Path,
62
+ include_abs_paths: bool,
63
+ ) -> dict:
64
+ active = terminal_sessions.get(session_id)
65
+ alive = bool(active and active.pty.isalive())
66
+ payload = {
67
+ "session_id": session_id,
68
+ "repo_path": _relative_repo_path(record.repo_path, repo_root),
69
+ "created_at": record.created_at,
70
+ "last_seen_at": record.last_seen_at,
71
+ "status": record.status,
72
+ "alive": alive,
73
+ }
74
+ if include_abs_paths:
75
+ payload["abs_repo_path"] = record.repo_path
76
+ return payload
77
+
78
+
79
+ def build_sessions_routes() -> APIRouter:
80
+ router = APIRouter()
81
+
82
+ @router.get("/api/sessions", response_model=SessionsResponse)
83
+ def list_sessions(request: Request, include_abs_paths: bool = False):
84
+ terminal_sessions = request.app.state.terminal_sessions
85
+ session_registry = request.app.state.session_registry
86
+ repo_to_session = request.app.state.repo_to_session
87
+ repo_root = Path(request.app.state.engine.repo_root)
88
+ allow_abs = _allow_abs_paths(request, include_abs_paths)
89
+ sessions = [
90
+ _session_payload(
91
+ session_id, record, terminal_sessions, repo_root, allow_abs
92
+ )
93
+ for session_id, record in session_registry.items()
94
+ ]
95
+ repo_to_session_payload = {
96
+ _relative_repo_key(repo_key, repo_root): session_id
97
+ for repo_key, session_id in repo_to_session.items()
98
+ }
99
+ payload = {
100
+ "sessions": sessions,
101
+ "repo_to_session": repo_to_session_payload,
102
+ }
103
+ if allow_abs:
104
+ payload["abs_repo_to_session"] = dict(repo_to_session)
105
+ return {
106
+ **payload,
107
+ }
108
+
109
+ @router.post("/api/sessions/stop", response_model=SessionStopResponse)
110
+ async def stop_session(request: Request, payload: SessionStopRequest):
111
+ session_id = payload.session_id
112
+ repo_path = payload.repo_path
113
+ if not session_id and not repo_path:
114
+ raise HTTPException(
115
+ status_code=400, detail="Provide session_id or repo_path"
116
+ )
117
+
118
+ terminal_sessions = request.app.state.terminal_sessions
119
+ session_registry = request.app.state.session_registry
120
+ repo_to_session = request.app.state.repo_to_session
121
+ terminal_lock = request.app.state.terminal_lock
122
+ engine = request.app.state.engine
123
+
124
+ if repo_path and isinstance(repo_path, str):
125
+ repo_root = Path(request.app.state.engine.repo_root)
126
+ normalized_repo_path = repo_path.strip()
127
+ if normalized_repo_path:
128
+ raw_path = Path(normalized_repo_path)
129
+ try:
130
+ # Reject absolute paths outright to prevent symlink traversal attacks
131
+ if raw_path.is_absolute():
132
+ raise ValueError("Absolute paths are not allowed")
133
+ # Only process relative paths, join with repo_root and resolve
134
+ resolved = (repo_root / raw_path).resolve()
135
+ # Verify the resolved path is still under repo_root
136
+ resolved.relative_to(repo_root)
137
+ except (OSError, RuntimeError, ValueError):
138
+ # On any resolution or containment failure, treat as invalid
139
+ normalized_repo_path = ""
140
+ else:
141
+ normalized_repo_path = str(resolved)
142
+ candidates: list[str] = []
143
+ if normalized_repo_path:
144
+ candidates.extend(
145
+ [normalized_repo_path, f"{normalized_repo_path}:opencode"]
146
+ )
147
+ for key in candidates:
148
+ mapped = repo_to_session.get(key)
149
+ if mapped:
150
+ session_id = mapped
151
+ break
152
+ if not isinstance(session_id, str) or not session_id:
153
+ raise HTTPException(status_code=404, detail="Session not found")
154
+ if session_id not in session_registry and session_id not in terminal_sessions:
155
+ raise HTTPException(status_code=404, detail="Session not found")
156
+
157
+ async with terminal_lock:
158
+ session = terminal_sessions.get(session_id)
159
+ if session:
160
+ session.close()
161
+ await session.wait_closed()
162
+ terminal_sessions.pop(session_id, None)
163
+ session_registry.pop(session_id, None)
164
+ repo_to_session = {
165
+ repo: sid for repo, sid in repo_to_session.items() if sid != session_id
166
+ }
167
+ request.app.state.repo_to_session = repo_to_session
168
+ persist_session_registry(
169
+ engine.state_path, session_registry, repo_to_session
170
+ )
171
+ request.app.state.session_state_last_write = time.time()
172
+ request.app.state.session_state_dirty = False
173
+
174
+ return {"status": "stopped", "session_id": session_id}
175
+
176
+ return router
@@ -0,0 +1,169 @@
1
+ """
2
+ Session settings routes for autorunner overrides.
3
+ """
4
+
5
+ from typing import Optional
6
+
7
+ from fastapi import APIRouter, HTTPException, Request
8
+
9
+ from ....core.state import RunnerState, load_state, save_state, state_lock
10
+ from ..schemas import SessionSettingsRequest, SessionSettingsResponse
11
+
12
+ ALLOWED_APPROVAL_POLICIES = {"never", "unlessTrusted"}
13
+ ALLOWED_SANDBOX_MODES = {"dangerFullAccess", "workspaceWrite"}
14
+
15
+
16
+ def _normalize_optional_string(value: object, field: str) -> Optional[str]:
17
+ if value is None:
18
+ return None
19
+ if not isinstance(value, str):
20
+ raise HTTPException(status_code=400, detail=f"{field} must be a string")
21
+ cleaned = value.strip()
22
+ return cleaned or None
23
+
24
+
25
+ def build_settings_routes() -> APIRouter:
26
+ router = APIRouter()
27
+
28
+ @router.get("/api/session/settings", response_model=SessionSettingsResponse)
29
+ def get_session_settings(request: Request):
30
+ state = load_state(request.app.state.engine.state_path)
31
+ return {
32
+ "autorunner_model_override": state.autorunner_model_override,
33
+ "autorunner_effort_override": state.autorunner_effort_override,
34
+ "autorunner_approval_policy": state.autorunner_approval_policy,
35
+ "autorunner_sandbox_mode": state.autorunner_sandbox_mode,
36
+ "autorunner_workspace_write_network": state.autorunner_workspace_write_network,
37
+ "runner_stop_after_runs": state.runner_stop_after_runs,
38
+ }
39
+
40
+ @router.post("/api/session/settings", response_model=SessionSettingsResponse)
41
+ def update_session_settings(request: Request, payload: SessionSettingsRequest):
42
+ updates = payload.model_dump(exclude_unset=True)
43
+ engine = request.app.state.engine
44
+ manager = request.app.state.manager
45
+ registry = request.app.state.app_server_threads
46
+ with state_lock(engine.state_path):
47
+ state = load_state(engine.state_path)
48
+ model_override = (
49
+ _normalize_optional_string(
50
+ updates.get("autorunner_model_override"),
51
+ "autorunner_model_override",
52
+ )
53
+ if "autorunner_model_override" in updates
54
+ else state.autorunner_model_override
55
+ )
56
+ effort_override = (
57
+ _normalize_optional_string(
58
+ updates.get("autorunner_effort_override"),
59
+ "autorunner_effort_override",
60
+ )
61
+ if "autorunner_effort_override" in updates
62
+ else state.autorunner_effort_override
63
+ )
64
+ approval_policy = (
65
+ _normalize_optional_string(
66
+ updates.get("autorunner_approval_policy"),
67
+ "autorunner_approval_policy",
68
+ )
69
+ if "autorunner_approval_policy" in updates
70
+ else state.autorunner_approval_policy
71
+ )
72
+ if approval_policy and approval_policy not in ALLOWED_APPROVAL_POLICIES:
73
+ raise HTTPException(
74
+ status_code=400,
75
+ detail="approval policy must be never or unlessTrusted",
76
+ )
77
+ sandbox_mode = (
78
+ _normalize_optional_string(
79
+ updates.get("autorunner_sandbox_mode"),
80
+ "autorunner_sandbox_mode",
81
+ )
82
+ if "autorunner_sandbox_mode" in updates
83
+ else state.autorunner_sandbox_mode
84
+ )
85
+ if sandbox_mode and sandbox_mode not in ALLOWED_SANDBOX_MODES:
86
+ raise HTTPException(
87
+ status_code=400,
88
+ detail="sandbox mode must be dangerFullAccess or workspaceWrite",
89
+ )
90
+ workspace_write_network = (
91
+ updates.get("autorunner_workspace_write_network")
92
+ if "autorunner_workspace_write_network" in updates
93
+ else state.autorunner_workspace_write_network
94
+ )
95
+ if (
96
+ "autorunner_workspace_write_network" in updates
97
+ and workspace_write_network is not None
98
+ and not isinstance(workspace_write_network, bool)
99
+ ):
100
+ raise HTTPException(
101
+ status_code=400,
102
+ detail="autorunner_workspace_write_network must be a boolean",
103
+ )
104
+ runner_stop_after_runs = (
105
+ updates.get("runner_stop_after_runs")
106
+ if "runner_stop_after_runs" in updates
107
+ else state.runner_stop_after_runs
108
+ )
109
+ if (
110
+ "runner_stop_after_runs" in updates
111
+ and runner_stop_after_runs is not None
112
+ and (
113
+ not isinstance(runner_stop_after_runs, int)
114
+ or isinstance(runner_stop_after_runs, bool)
115
+ or runner_stop_after_runs <= 0
116
+ )
117
+ ):
118
+ raise HTTPException(
119
+ status_code=400,
120
+ detail="runner_stop_after_runs must be a positive integer",
121
+ )
122
+
123
+ thread_reset_required = any(
124
+ (
125
+ model_override != state.autorunner_model_override,
126
+ effort_override != state.autorunner_effort_override,
127
+ approval_policy != state.autorunner_approval_policy,
128
+ sandbox_mode != state.autorunner_sandbox_mode,
129
+ workspace_write_network != state.autorunner_workspace_write_network,
130
+ runner_stop_after_runs != state.runner_stop_after_runs,
131
+ )
132
+ )
133
+ if thread_reset_required and manager.running:
134
+ raise HTTPException(
135
+ status_code=409,
136
+ detail="Cannot change autorunner settings while a run is active",
137
+ )
138
+
139
+ new_state = RunnerState(
140
+ last_run_id=state.last_run_id,
141
+ status=state.status,
142
+ last_exit_code=state.last_exit_code,
143
+ last_run_started_at=state.last_run_started_at,
144
+ last_run_finished_at=state.last_run_finished_at,
145
+ autorunner_agent_override=state.autorunner_agent_override,
146
+ autorunner_model_override=model_override,
147
+ autorunner_effort_override=effort_override,
148
+ autorunner_approval_policy=approval_policy,
149
+ autorunner_sandbox_mode=sandbox_mode,
150
+ autorunner_workspace_write_network=workspace_write_network,
151
+ runner_stop_after_runs=runner_stop_after_runs,
152
+ runner_pid=state.runner_pid,
153
+ sessions=state.sessions,
154
+ repo_to_session=state.repo_to_session,
155
+ )
156
+ save_state(engine.state_path, new_state)
157
+ if thread_reset_required:
158
+ registry.reset_thread("autorunner")
159
+
160
+ return {
161
+ "autorunner_model_override": model_override,
162
+ "autorunner_effort_override": effort_override,
163
+ "autorunner_approval_policy": approval_policy,
164
+ "autorunner_sandbox_mode": sandbox_mode,
165
+ "autorunner_workspace_write_network": workspace_write_network,
166
+ "runner_stop_after_runs": runner_stop_after_runs,
167
+ }
168
+
169
+ return router