codex-autorunner 0.1.1__py3-none-any.whl → 1.0.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 (226) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/__init__.py +20 -0
  3. codex_autorunner/agents/base.py +2 -2
  4. codex_autorunner/agents/codex/harness.py +1 -1
  5. codex_autorunner/agents/opencode/__init__.py +4 -0
  6. codex_autorunner/agents/opencode/agent_config.py +104 -0
  7. codex_autorunner/agents/opencode/client.py +305 -28
  8. codex_autorunner/agents/opencode/harness.py +71 -20
  9. codex_autorunner/agents/opencode/logging.py +225 -0
  10. codex_autorunner/agents/opencode/run_prompt.py +261 -0
  11. codex_autorunner/agents/opencode/runtime.py +1202 -132
  12. codex_autorunner/agents/opencode/supervisor.py +194 -68
  13. codex_autorunner/agents/registry.py +258 -0
  14. codex_autorunner/agents/types.py +2 -2
  15. codex_autorunner/api.py +25 -0
  16. codex_autorunner/bootstrap.py +19 -40
  17. codex_autorunner/cli.py +234 -151
  18. codex_autorunner/core/about_car.py +44 -32
  19. codex_autorunner/core/adapter_utils.py +21 -0
  20. codex_autorunner/core/app_server_events.py +15 -6
  21. codex_autorunner/core/app_server_logging.py +55 -15
  22. codex_autorunner/core/app_server_prompts.py +28 -259
  23. codex_autorunner/core/app_server_threads.py +15 -26
  24. codex_autorunner/core/circuit_breaker.py +183 -0
  25. codex_autorunner/core/codex_runner.py +6 -0
  26. codex_autorunner/core/config.py +555 -133
  27. codex_autorunner/core/docs.py +54 -9
  28. codex_autorunner/core/drafts.py +82 -0
  29. codex_autorunner/core/engine.py +828 -274
  30. codex_autorunner/core/exceptions.py +60 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +178 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +75 -0
  35. codex_autorunner/core/flows/runtime.py +351 -0
  36. codex_autorunner/core/flows/store.py +485 -0
  37. codex_autorunner/core/flows/transition.py +133 -0
  38. codex_autorunner/core/flows/worker_process.py +242 -0
  39. codex_autorunner/core/hub.py +21 -13
  40. codex_autorunner/core/locks.py +118 -1
  41. codex_autorunner/core/logging_utils.py +9 -6
  42. codex_autorunner/core/path_utils.py +123 -0
  43. codex_autorunner/core/prompt.py +15 -7
  44. codex_autorunner/core/redaction.py +29 -0
  45. codex_autorunner/core/retry.py +61 -0
  46. codex_autorunner/core/review.py +888 -0
  47. codex_autorunner/core/review_context.py +161 -0
  48. codex_autorunner/core/run_index.py +223 -0
  49. codex_autorunner/core/runner_controller.py +44 -1
  50. codex_autorunner/core/runner_process.py +30 -1
  51. codex_autorunner/core/sqlite_utils.py +32 -0
  52. codex_autorunner/core/state.py +273 -44
  53. codex_autorunner/core/static_assets.py +55 -0
  54. codex_autorunner/core/supervisor_utils.py +67 -0
  55. codex_autorunner/core/text_delta_coalescer.py +43 -0
  56. codex_autorunner/core/update.py +20 -11
  57. codex_autorunner/core/update_runner.py +2 -0
  58. codex_autorunner/core/usage.py +107 -75
  59. codex_autorunner/core/utils.py +167 -3
  60. codex_autorunner/discovery.py +3 -3
  61. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  62. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  63. codex_autorunner/integrations/agents/__init__.py +27 -0
  64. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  65. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  66. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  67. codex_autorunner/integrations/agents/run_event.py +71 -0
  68. codex_autorunner/integrations/app_server/client.py +708 -153
  69. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  70. codex_autorunner/integrations/telegram/adapter.py +474 -185
  71. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  72. codex_autorunner/integrations/telegram/config.py +239 -1
  73. codex_autorunner/integrations/telegram/constants.py +19 -1
  74. codex_autorunner/integrations/telegram/dispatch.py +44 -8
  75. codex_autorunner/integrations/telegram/doctor.py +47 -0
  76. codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
  77. codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
  78. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
  79. codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
  80. codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
  81. codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
  82. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  83. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
  84. codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
  85. codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
  86. codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
  87. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
  88. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
  89. codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
  90. codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
  91. codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
  92. codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
  93. codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
  94. codex_autorunner/integrations/telegram/helpers.py +90 -18
  95. codex_autorunner/integrations/telegram/notifications.py +126 -35
  96. codex_autorunner/integrations/telegram/outbox.py +214 -43
  97. codex_autorunner/integrations/telegram/progress_stream.py +42 -19
  98. codex_autorunner/integrations/telegram/runtime.py +24 -13
  99. codex_autorunner/integrations/telegram/service.py +500 -129
  100. codex_autorunner/integrations/telegram/state.py +1278 -330
  101. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  102. codex_autorunner/integrations/telegram/transport.py +37 -4
  103. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  104. codex_autorunner/integrations/telegram/types.py +22 -2
  105. codex_autorunner/integrations/telegram/voice.py +14 -15
  106. codex_autorunner/manifest.py +2 -0
  107. codex_autorunner/plugin_api.py +22 -0
  108. codex_autorunner/routes/__init__.py +25 -14
  109. codex_autorunner/routes/agents.py +18 -78
  110. codex_autorunner/routes/analytics.py +239 -0
  111. codex_autorunner/routes/base.py +142 -113
  112. codex_autorunner/routes/file_chat.py +836 -0
  113. codex_autorunner/routes/flows.py +980 -0
  114. codex_autorunner/routes/messages.py +459 -0
  115. codex_autorunner/routes/repos.py +17 -0
  116. codex_autorunner/routes/review.py +148 -0
  117. codex_autorunner/routes/sessions.py +16 -8
  118. codex_autorunner/routes/settings.py +22 -0
  119. codex_autorunner/routes/shared.py +33 -3
  120. codex_autorunner/routes/system.py +22 -1
  121. codex_autorunner/routes/usage.py +87 -0
  122. codex_autorunner/routes/voice.py +5 -13
  123. codex_autorunner/routes/workspace.py +271 -0
  124. codex_autorunner/server.py +2 -1
  125. codex_autorunner/static/agentControls.js +9 -1
  126. codex_autorunner/static/agentEvents.js +248 -0
  127. codex_autorunner/static/app.js +27 -22
  128. codex_autorunner/static/autoRefresh.js +29 -1
  129. codex_autorunner/static/bootstrap.js +1 -0
  130. codex_autorunner/static/bus.js +1 -0
  131. codex_autorunner/static/cache.js +1 -0
  132. codex_autorunner/static/constants.js +20 -4
  133. codex_autorunner/static/dashboard.js +162 -150
  134. codex_autorunner/static/diffRenderer.js +37 -0
  135. codex_autorunner/static/docChatCore.js +324 -0
  136. codex_autorunner/static/docChatStorage.js +65 -0
  137. codex_autorunner/static/docChatVoice.js +65 -0
  138. codex_autorunner/static/docEditor.js +133 -0
  139. codex_autorunner/static/env.js +1 -0
  140. codex_autorunner/static/eventSummarizer.js +166 -0
  141. codex_autorunner/static/fileChat.js +182 -0
  142. codex_autorunner/static/health.js +155 -0
  143. codex_autorunner/static/hub.js +67 -126
  144. codex_autorunner/static/index.html +788 -807
  145. codex_autorunner/static/liveUpdates.js +59 -0
  146. codex_autorunner/static/loader.js +1 -0
  147. codex_autorunner/static/messages.js +470 -0
  148. codex_autorunner/static/mobileCompact.js +2 -1
  149. codex_autorunner/static/settings.js +24 -205
  150. codex_autorunner/static/styles.css +7577 -3758
  151. codex_autorunner/static/tabs.js +28 -5
  152. codex_autorunner/static/terminal.js +14 -0
  153. codex_autorunner/static/terminalManager.js +53 -59
  154. codex_autorunner/static/ticketChatActions.js +333 -0
  155. codex_autorunner/static/ticketChatEvents.js +16 -0
  156. codex_autorunner/static/ticketChatStorage.js +16 -0
  157. codex_autorunner/static/ticketChatStream.js +264 -0
  158. codex_autorunner/static/ticketEditor.js +750 -0
  159. codex_autorunner/static/ticketVoice.js +9 -0
  160. codex_autorunner/static/tickets.js +1315 -0
  161. codex_autorunner/static/utils.js +32 -3
  162. codex_autorunner/static/voice.js +21 -7
  163. codex_autorunner/static/workspace.js +672 -0
  164. codex_autorunner/static/workspaceApi.js +53 -0
  165. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  166. codex_autorunner/tickets/__init__.py +20 -0
  167. codex_autorunner/tickets/agent_pool.py +377 -0
  168. codex_autorunner/tickets/files.py +85 -0
  169. codex_autorunner/tickets/frontmatter.py +55 -0
  170. codex_autorunner/tickets/lint.py +102 -0
  171. codex_autorunner/tickets/models.py +95 -0
  172. codex_autorunner/tickets/outbox.py +232 -0
  173. codex_autorunner/tickets/replies.py +179 -0
  174. codex_autorunner/tickets/runner.py +823 -0
  175. codex_autorunner/tickets/spec_ingest.py +77 -0
  176. codex_autorunner/voice/capture.py +7 -7
  177. codex_autorunner/voice/service.py +51 -9
  178. codex_autorunner/web/app.py +419 -199
  179. codex_autorunner/web/hub_jobs.py +13 -2
  180. codex_autorunner/web/middleware.py +47 -13
  181. codex_autorunner/web/pty_session.py +26 -13
  182. codex_autorunner/web/schemas.py +114 -109
  183. codex_autorunner/web/static_assets.py +55 -42
  184. codex_autorunner/web/static_refresh.py +86 -0
  185. codex_autorunner/workspace/__init__.py +40 -0
  186. codex_autorunner/workspace/paths.py +319 -0
  187. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
  188. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  189. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  190. codex_autorunner/core/doc_chat.py +0 -1415
  191. codex_autorunner/core/snapshot.py +0 -580
  192. codex_autorunner/integrations/github/chatops.py +0 -268
  193. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  194. codex_autorunner/routes/docs.py +0 -381
  195. codex_autorunner/routes/github.py +0 -327
  196. codex_autorunner/routes/runs.py +0 -118
  197. codex_autorunner/spec_ingest.py +0 -788
  198. codex_autorunner/static/docChatActions.js +0 -279
  199. codex_autorunner/static/docChatEvents.js +0 -300
  200. codex_autorunner/static/docChatRender.js +0 -205
  201. codex_autorunner/static/docChatStream.js +0 -361
  202. codex_autorunner/static/docs.js +0 -20
  203. codex_autorunner/static/docsClipboard.js +0 -69
  204. codex_autorunner/static/docsCrud.js +0 -257
  205. codex_autorunner/static/docsDocUpdates.js +0 -62
  206. codex_autorunner/static/docsDrafts.js +0 -16
  207. codex_autorunner/static/docsElements.js +0 -69
  208. codex_autorunner/static/docsInit.js +0 -274
  209. codex_autorunner/static/docsParse.js +0 -160
  210. codex_autorunner/static/docsSnapshot.js +0 -87
  211. codex_autorunner/static/docsSpecIngest.js +0 -263
  212. codex_autorunner/static/docsState.js +0 -127
  213. codex_autorunner/static/docsThreadRegistry.js +0 -44
  214. codex_autorunner/static/docsUi.js +0 -153
  215. codex_autorunner/static/docsVoice.js +0 -56
  216. codex_autorunner/static/github.js +0 -442
  217. codex_autorunner/static/logs.js +0 -640
  218. codex_autorunner/static/runs.js +0 -409
  219. codex_autorunner/static/snapshot.js +0 -124
  220. codex_autorunner/static/state.js +0 -86
  221. codex_autorunner/static/todoPreview.js +0 -27
  222. codex_autorunner/workspace.py +0 -16
  223. codex_autorunner-0.1.1.dist-info/RECORD +0 -191
  224. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  225. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  226. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,459 @@
1
+ """Inbox endpoints for agent dispatches and human replies.
2
+
3
+ These endpoints provide a thin wrapper over the durable on-disk ticket_flow
4
+ dispatch history (agent -> human) and reply history (human -> agent).
5
+
6
+ Domain terminology:
7
+ - Dispatch: Agent-to-human communication (mode: "notify" for FYI, "pause" for handoff)
8
+ - Reply: Human-to-agent response
9
+ - Handoff: A dispatch with mode="pause" that requires human action
10
+
11
+ The UI contract is intentionally filesystem-backed:
12
+ * Dispatches come from `.codex-autorunner/runs/<run_id>/dispatch_history/<seq>/`.
13
+ * Human replies are written to USER_REPLY.md + reply/* and immediately archived
14
+ into `.codex-autorunner/runs/<run_id>/reply_history/<seq>/`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import re
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any, Optional
25
+ from urllib.parse import quote
26
+
27
+ import yaml
28
+ from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
29
+
30
+ from ..core.flows.models import FlowRunRecord, FlowRunStatus
31
+ from ..core.flows.store import FlowStore
32
+ from ..core.utils import find_repo_root
33
+ from ..tickets.files import safe_relpath
34
+ from ..tickets.outbox import parse_dispatch, resolve_outbox_paths
35
+ from ..tickets.replies import (
36
+ dispatch_reply,
37
+ ensure_reply_dirs,
38
+ next_reply_seq,
39
+ parse_user_reply,
40
+ resolve_reply_paths,
41
+ )
42
+
43
+ _logger = logging.getLogger(__name__)
44
+
45
+
46
+ def _flows_db_path(repo_root: Path) -> Path:
47
+ return repo_root / ".codex-autorunner" / "flows.db"
48
+
49
+
50
+ def _load_store_or_404(db_path: Path) -> FlowStore:
51
+ store = FlowStore(db_path)
52
+ try:
53
+ store.initialize()
54
+ return store
55
+ except Exception as exc:
56
+ raise HTTPException(
57
+ status_code=404, detail="Flows database unavailable"
58
+ ) from exc
59
+
60
+
61
+ def _timestamp(path: Path) -> Optional[str]:
62
+ try:
63
+ return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat()
64
+ except OSError:
65
+ return None
66
+
67
+
68
+ def _safe_attachment_name(name: str) -> str:
69
+ base = os.path.basename(name or "").strip()
70
+ if not base:
71
+ raise ValueError("Missing attachment filename")
72
+ if base.lower() == "user_reply.md":
73
+ raise ValueError("Attachment filename reserved: USER_REPLY.md")
74
+ if not re.fullmatch(r"[A-Za-z0-9._-]+", base):
75
+ raise ValueError(
76
+ "Invalid attachment filename; use only letters, digits, dot, underscore, dash"
77
+ )
78
+ return base
79
+
80
+
81
+ def _iter_seq_dirs(history_dir: Path) -> list[tuple[int, Path]]:
82
+ if not history_dir.exists() or not history_dir.is_dir():
83
+ return []
84
+ out: list[tuple[int, Path]] = []
85
+ try:
86
+ for child in history_dir.iterdir():
87
+ try:
88
+ if not child.is_dir():
89
+ continue
90
+ name = child.name
91
+ if not (len(name) == 4 and name.isdigit()):
92
+ continue
93
+ out.append((int(name), child))
94
+ except OSError:
95
+ continue
96
+ except OSError:
97
+ return []
98
+ out.sort(key=lambda x: x[0])
99
+ return out
100
+
101
+
102
+ def _collect_dispatch_history(
103
+ *, repo_root: Path, run_id: str, record_input: dict[str, Any]
104
+ ) -> list[dict[str, Any]]:
105
+ """Collect all dispatches from the dispatch history directory."""
106
+ workspace_root = Path(record_input.get("workspace_root") or repo_root)
107
+ runs_dir = Path(record_input.get("runs_dir") or ".codex-autorunner/runs")
108
+ outbox_paths = resolve_outbox_paths(
109
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
110
+ )
111
+ history: list[dict[str, Any]] = []
112
+ for seq, entry_dir in reversed(_iter_seq_dirs(outbox_paths.dispatch_history_dir)):
113
+ dispatch_path = entry_dir / "DISPATCH.md"
114
+ dispatch, errors = parse_dispatch(dispatch_path)
115
+ files: list[dict[str, str]] = []
116
+ try:
117
+ for child in sorted(entry_dir.iterdir(), key=lambda p: p.name):
118
+ try:
119
+ if child.name.startswith("."):
120
+ continue
121
+ if child.name == "DISPATCH.md":
122
+ continue
123
+ if child.is_dir():
124
+ continue
125
+ rel = child.name
126
+ url = f"api/flows/{run_id}/dispatch_history/{seq:04d}/{quote(rel)}"
127
+ size = None
128
+ try:
129
+ size = child.stat().st_size
130
+ except OSError:
131
+ size = None
132
+ files.append({"name": child.name, "url": url, "size": size})
133
+ except OSError:
134
+ continue
135
+ except OSError:
136
+ files = []
137
+ created_at = _timestamp(dispatch_path) or _timestamp(entry_dir)
138
+ history.append(
139
+ {
140
+ "seq": seq,
141
+ "dir": safe_relpath(entry_dir, workspace_root),
142
+ "created_at": created_at,
143
+ "dispatch": (
144
+ {
145
+ "mode": dispatch.mode,
146
+ "title": dispatch.title,
147
+ "body": dispatch.body,
148
+ "extra": dispatch.extra,
149
+ "is_handoff": dispatch.is_handoff,
150
+ }
151
+ if dispatch
152
+ else None
153
+ ),
154
+ "errors": errors,
155
+ "files": files,
156
+ }
157
+ )
158
+ return history
159
+
160
+
161
+ def _collect_reply_history(
162
+ *, repo_root: Path, run_id: str, record_input: dict[str, Any]
163
+ ):
164
+ workspace_root = Path(record_input.get("workspace_root") or repo_root)
165
+ runs_dir = Path(record_input.get("runs_dir") or ".codex-autorunner/runs")
166
+ reply_paths = resolve_reply_paths(
167
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
168
+ )
169
+ history: list[dict[str, Any]] = []
170
+ for seq, entry_dir in reversed(_iter_seq_dirs(reply_paths.reply_history_dir)):
171
+ reply_path = entry_dir / "USER_REPLY.md"
172
+ reply, errors = (
173
+ parse_user_reply(reply_path)
174
+ if reply_path.exists()
175
+ else (None, ["USER_REPLY.md missing"])
176
+ )
177
+ files: list[dict[str, str]] = []
178
+ try:
179
+ for child in sorted(entry_dir.iterdir(), key=lambda p: p.name):
180
+ try:
181
+ if child.name.startswith("."):
182
+ continue
183
+ if child.name == "USER_REPLY.md":
184
+ continue
185
+ if child.is_dir():
186
+ continue
187
+ rel = child.name
188
+ url = f"api/flows/{run_id}/reply_history/{seq:04d}/{quote(rel)}"
189
+ size = None
190
+ try:
191
+ size = child.stat().st_size
192
+ except OSError:
193
+ size = None
194
+ files.append({"name": child.name, "url": url, "size": size})
195
+ except OSError:
196
+ continue
197
+ except OSError:
198
+ files = []
199
+ created_at = _timestamp(reply_path) or _timestamp(entry_dir)
200
+ history.append(
201
+ {
202
+ "seq": seq,
203
+ "dir": safe_relpath(entry_dir, workspace_root),
204
+ "created_at": created_at,
205
+ "reply": (
206
+ {"title": reply.title, "body": reply.body, "extra": reply.extra}
207
+ if reply
208
+ else None
209
+ ),
210
+ "errors": errors,
211
+ "files": files,
212
+ }
213
+ )
214
+ return history
215
+
216
+
217
+ def _ticket_state_snapshot(record: FlowRunRecord) -> dict[str, Any]:
218
+ state = record.state if isinstance(record.state, dict) else {}
219
+ ticket_state = state.get("ticket_engine") if isinstance(state, dict) else {}
220
+ if not isinstance(ticket_state, dict):
221
+ ticket_state = {}
222
+ allowed_keys = {
223
+ "current_ticket",
224
+ "total_turns",
225
+ "ticket_turns",
226
+ "dispatch_seq",
227
+ "reply_seq",
228
+ "reason",
229
+ "status",
230
+ }
231
+ return {k: ticket_state.get(k) for k in allowed_keys if k in ticket_state}
232
+
233
+
234
+ def build_messages_routes() -> APIRouter:
235
+ router = APIRouter()
236
+
237
+ @router.get("/api/messages/active")
238
+ def get_active_message(request: Request):
239
+ repo_root = find_repo_root()
240
+ db_path = _flows_db_path(repo_root)
241
+ if not db_path.exists():
242
+ return {"active": False}
243
+ store = FlowStore(db_path)
244
+ try:
245
+ store.initialize()
246
+ except Exception:
247
+ # Corrupt flows db should not 500 the UI.
248
+ return {"active": False}
249
+
250
+ paused = store.list_flow_runs(
251
+ flow_type="ticket_flow", status=FlowRunStatus.PAUSED
252
+ )
253
+ if not paused:
254
+ return {"active": False}
255
+
256
+ # Walk paused runs (newest first as returned by FlowStore) until we find
257
+ # one with at least one archived dispatch. This avoids hiding
258
+ # older paused runs that do have history when the newest paused run
259
+ # hasn't yet written DISPATCH.md.
260
+ for record in paused:
261
+ history = _collect_dispatch_history(
262
+ repo_root=repo_root,
263
+ run_id=str(record.id),
264
+ record_input=dict(record.input_data or {}),
265
+ )
266
+ if not history:
267
+ continue
268
+ latest = history[0]
269
+ return {
270
+ "active": True,
271
+ "run_id": record.id,
272
+ "flow_type": record.flow_type,
273
+ "status": record.status.value,
274
+ "seq": latest.get("seq"),
275
+ "dispatch": latest.get("dispatch"),
276
+ "files": latest.get("files"),
277
+ "open_url": f"?tab=inbox&run_id={record.id}",
278
+ }
279
+
280
+ return {"active": False}
281
+
282
+ @router.get("/api/messages/threads")
283
+ def list_threads():
284
+ repo_root = find_repo_root()
285
+ db_path = _flows_db_path(repo_root)
286
+ if not db_path.exists():
287
+ return {"conversations": []}
288
+ store = FlowStore(db_path)
289
+ try:
290
+ store.initialize()
291
+ except Exception:
292
+ return {"conversations": []}
293
+ runs = store.list_flow_runs(flow_type="ticket_flow")
294
+ conversations: list[dict[str, Any]] = []
295
+ for record in runs:
296
+ record_input = dict(record.input_data or {})
297
+ dispatch_history = _collect_dispatch_history(
298
+ repo_root=repo_root,
299
+ run_id=str(record.id),
300
+ record_input=record_input,
301
+ )
302
+ if not dispatch_history:
303
+ continue
304
+ latest = dispatch_history[0]
305
+ reply_history = _collect_reply_history(
306
+ repo_root=repo_root,
307
+ run_id=str(record.id),
308
+ record_input=record_input,
309
+ )
310
+ conversations.append(
311
+ {
312
+ "run_id": record.id,
313
+ "flow_type": record.flow_type,
314
+ "status": record.status.value,
315
+ "created_at": record.created_at,
316
+ "started_at": record.started_at,
317
+ "finished_at": record.finished_at,
318
+ "current_step": record.current_step,
319
+ "latest": latest,
320
+ "dispatch_count": len(dispatch_history),
321
+ "reply_count": len(reply_history),
322
+ "ticket_state": _ticket_state_snapshot(record),
323
+ "open_url": f"?tab=inbox&run_id={record.id}",
324
+ }
325
+ )
326
+ return {"conversations": conversations}
327
+
328
+ @router.get("/api/messages/threads/{run_id}")
329
+ def get_thread(run_id: str):
330
+ repo_root = find_repo_root()
331
+ db_path = _flows_db_path(repo_root)
332
+ empty_response = {
333
+ "dispatch_history": [],
334
+ "reply_history": [],
335
+ "dispatch_count": 0,
336
+ "reply_count": 0,
337
+ }
338
+ if not db_path.exists():
339
+ return empty_response
340
+ store = _load_store_or_404(db_path)
341
+ try:
342
+ record = store.get_flow_run(run_id)
343
+ finally:
344
+ try:
345
+ store.close()
346
+ except Exception:
347
+ pass
348
+ if not record:
349
+ return empty_response
350
+ input_data = dict(record.input_data or {})
351
+ dispatch_history = _collect_dispatch_history(
352
+ repo_root=repo_root, run_id=run_id, record_input=input_data
353
+ )
354
+ reply_history = _collect_reply_history(
355
+ repo_root=repo_root, run_id=run_id, record_input=input_data
356
+ )
357
+ return {
358
+ "run": {
359
+ "id": record.id,
360
+ "flow_type": record.flow_type,
361
+ "status": record.status.value,
362
+ "created_at": record.created_at,
363
+ "started_at": record.started_at,
364
+ "finished_at": record.finished_at,
365
+ "current_step": record.current_step,
366
+ "error_message": record.error_message,
367
+ },
368
+ "dispatch_history": dispatch_history,
369
+ "reply_history": reply_history,
370
+ "dispatch_count": len(dispatch_history),
371
+ "reply_count": len(reply_history),
372
+ "ticket_state": _ticket_state_snapshot(record),
373
+ }
374
+
375
+ @router.post("/api/messages/{run_id}/reply")
376
+ async def post_reply(
377
+ run_id: str,
378
+ body: str = Form(""),
379
+ title: Optional[str] = Form(None),
380
+ # NOTE: FastAPI/starlette will supply either a single UploadFile or a list
381
+ # depending on how the multipart form is encoded. Declaring this as a
382
+ # concrete list avoids a common 422 where a single file upload is treated
383
+ # as a non-list value.
384
+ files: list[UploadFile] = File(default=[]), # noqa: B006,B008
385
+ ):
386
+ repo_root = find_repo_root()
387
+ db_path = _flows_db_path(repo_root)
388
+ if not db_path.exists():
389
+ raise HTTPException(status_code=404, detail="No flows database")
390
+ store = _load_store_or_404(db_path)
391
+ try:
392
+ record = store.get_flow_run(run_id)
393
+ finally:
394
+ try:
395
+ store.close()
396
+ except Exception:
397
+ pass
398
+ if not record:
399
+ raise HTTPException(status_code=404, detail="Run not found")
400
+
401
+ input_data = dict(record.input_data or {})
402
+ workspace_root = Path(input_data.get("workspace_root") or repo_root)
403
+ runs_dir = Path(input_data.get("runs_dir") or ".codex-autorunner/runs")
404
+ reply_paths = resolve_reply_paths(
405
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
406
+ )
407
+ ensure_reply_dirs(reply_paths)
408
+
409
+ cleaned_title = (
410
+ title.strip() if isinstance(title, str) and title.strip() else None
411
+ )
412
+ cleaned_body = body or ""
413
+
414
+ if cleaned_title:
415
+ fm = yaml.safe_dump({"title": cleaned_title}, sort_keys=False).strip()
416
+ raw = f"---\n{fm}\n---\n\n{cleaned_body}\n"
417
+ else:
418
+ raw = cleaned_body
419
+ if raw and not raw.endswith("\n"):
420
+ raw += "\n"
421
+
422
+ try:
423
+ reply_paths.user_reply_path.parent.mkdir(parents=True, exist_ok=True)
424
+ reply_paths.user_reply_path.write_text(raw, encoding="utf-8")
425
+ except OSError as exc:
426
+ raise HTTPException(
427
+ status_code=500, detail=f"Failed to write USER_REPLY.md: {exc}"
428
+ ) from exc
429
+
430
+ for upload in files:
431
+ try:
432
+ filename = _safe_attachment_name(upload.filename or "")
433
+ except ValueError as exc:
434
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
435
+ dest = reply_paths.reply_dir / filename
436
+ data = await upload.read()
437
+ try:
438
+ dest.write_bytes(data)
439
+ except OSError as exc:
440
+ raise HTTPException(
441
+ status_code=500, detail=f"Failed to write attachment: {exc}"
442
+ ) from exc
443
+
444
+ seq = next_reply_seq(reply_paths.reply_history_dir)
445
+ dispatch, errors = dispatch_reply(reply_paths, next_seq=seq)
446
+ if errors:
447
+ raise HTTPException(status_code=400, detail=errors)
448
+ if dispatch is None:
449
+ raise HTTPException(status_code=500, detail="Failed to archive reply")
450
+ return {
451
+ "status": "ok",
452
+ "seq": dispatch.seq,
453
+ "reply": {"title": dispatch.reply.title, "body": dispatch.reply.body},
454
+ }
455
+
456
+ return router
457
+
458
+
459
+ __all__ = ["build_messages_routes"]
@@ -123,6 +123,23 @@ def build_repos_routes() -> APIRouter:
123
123
  )
124
124
  save_state(engine.state_path, new_state)
125
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)
126
143
  return {"running": manager.running}
127
144
 
128
145
  @router.post("/api/run/resume", response_model=RunControlResponse)
@@ -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 ..core.review import ReviewBusyError, ReviewError, ReviewService
11
+ from ..web.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
@@ -2,6 +2,7 @@
2
2
  Terminal session registry routes.
3
3
  """
4
4
 
5
+ import logging
5
6
  import time
6
7
  from pathlib import Path
7
8
 
@@ -14,6 +15,8 @@ from ..web.schemas import (
14
15
  SessionStopResponse,
15
16
  )
16
17
 
18
+ logger = logging.getLogger("codex_autorunner.routes.sessions")
19
+
17
20
 
18
21
  def _relative_repo_path(repo_path: str, repo_root: Path) -> str:
19
22
  path = Path(repo_path)
@@ -22,7 +25,8 @@ def _relative_repo_path(repo_path: str, repo_root: Path) -> str:
22
25
  try:
23
26
  rel = path.resolve().relative_to(repo_root)
24
27
  return rel.as_posix() or "."
25
- except Exception:
28
+ except ValueError as exc:
29
+ logger.debug("Failed to resolve relative path: %s", exc)
26
30
  return path.name
27
31
 
28
32
 
@@ -121,21 +125,25 @@ def build_sessions_routes() -> APIRouter:
121
125
  repo_root = Path(request.app.state.engine.repo_root)
122
126
  normalized_repo_path = repo_path.strip()
123
127
  if normalized_repo_path:
124
- candidate = Path(normalized_repo_path)
125
- if not candidate.is_absolute():
126
- candidate = (repo_root / candidate).resolve()
128
+ raw_path = Path(normalized_repo_path)
127
129
  try:
128
- candidate.relative_to(repo_root)
129
- except ValueError:
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
130
139
  normalized_repo_path = ""
131
140
  else:
132
- normalized_repo_path = str(candidate)
141
+ normalized_repo_path = str(resolved)
133
142
  candidates: list[str] = []
134
143
  if normalized_repo_path:
135
144
  candidates.extend(
136
145
  [normalized_repo_path, f"{normalized_repo_path}:opencode"]
137
146
  )
138
- candidates.extend([repo_path, f"{repo_path}:opencode"])
139
147
  for key in candidates:
140
148
  mapped = repo_to_session.get(key)
141
149
  if mapped: