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
@@ -1,459 +1,7 @@
1
- """Inbox endpoints for agent dispatches and human replies.
1
+ """Backward-compatible message routes."""
2
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).
3
+ import sys
5
4
 
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
5
+ from ..surfaces.web.routes import messages as _messages
10
6
 
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"]
7
+ sys.modules[__name__] = _messages
@@ -1,197 +1,3 @@
1
- """
2
- Repository run control routes: start, stop, resume, reset, kill.
3
- """
1
+ """Backward-compatible repo routes."""
4
2
 
5
- from typing import Optional
6
-
7
- from fastapi import APIRouter, HTTPException, Request
8
-
9
- from ..core.engine import LockError, clear_stale_lock
10
- from ..core.state import RunnerState, load_state, now_iso, save_state, state_lock
11
- from ..web.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
3
+ from ..surfaces.web.routes.repos import * # noqa: F401,F403