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