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,1164 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import uuid
8
+ from dataclasses import asdict
9
+ from pathlib import Path, PurePosixPath
10
+ from typing import IO, Dict, Optional, Tuple, Union
11
+ from urllib.parse import quote
12
+
13
+ from fastapi import APIRouter, HTTPException, Request
14
+ from fastapi.responses import FileResponse, StreamingResponse
15
+ from pydantic import BaseModel, Field
16
+
17
+ from ....agents.registry import validate_agent_id
18
+ from ....core.config import load_repo_config
19
+ from ....core.engine import Engine
20
+ from ....core.flows import (
21
+ FlowController,
22
+ FlowDefinition,
23
+ FlowRunRecord,
24
+ FlowRunStatus,
25
+ FlowStore,
26
+ )
27
+ from ....core.flows.reconciler import reconcile_flow_run
28
+ from ....core.flows.ux_helpers import (
29
+ bootstrap_check as ux_bootstrap_check,
30
+ )
31
+ from ....core.flows.ux_helpers import (
32
+ build_flow_status_snapshot,
33
+ ensure_worker,
34
+ issue_md_path,
35
+ seed_issue_from_github,
36
+ seed_issue_from_text,
37
+ )
38
+ from ....core.flows.worker_process import FlowWorkerHealth, check_worker_health
39
+ from ....core.utils import atomic_write, find_repo_root
40
+ from ....flows.ticket_flow import build_ticket_flow_definition
41
+ from ....integrations.agents.wiring import (
42
+ build_agent_backend_factory,
43
+ build_app_server_supervisor_factory,
44
+ )
45
+ from ....integrations.github.service import GitHubError, GitHubService
46
+ from ....tickets import AgentPool
47
+ from ....tickets.files import (
48
+ list_ticket_paths,
49
+ parse_ticket_index,
50
+ read_ticket,
51
+ safe_relpath,
52
+ )
53
+ from ....tickets.frontmatter import parse_markdown_frontmatter
54
+ from ....tickets.lint import lint_ticket_frontmatter
55
+ from ....tickets.outbox import parse_dispatch, resolve_outbox_paths
56
+ from ..schemas import (
57
+ TicketCreateRequest,
58
+ TicketDeleteResponse,
59
+ TicketResponse,
60
+ TicketUpdateRequest,
61
+ )
62
+
63
+ _logger = logging.getLogger(__name__)
64
+
65
+ _active_workers: Dict[
66
+ str, Tuple[Optional[subprocess.Popen], Optional[IO[bytes]], Optional[IO[bytes]]]
67
+ ] = {}
68
+ _controller_cache: Dict[tuple[Path, str], FlowController] = {}
69
+ _definition_cache: Dict[tuple[Path, str], FlowDefinition] = {}
70
+ _supported_flow_types = ("ticket_flow",)
71
+
72
+
73
+ def _flow_paths(repo_root: Path) -> tuple[Path, Path]:
74
+ repo_root = repo_root.resolve()
75
+ db_path = repo_root / ".codex-autorunner" / "flows.db"
76
+ artifacts_root = repo_root / ".codex-autorunner" / "flows"
77
+ return db_path, artifacts_root
78
+
79
+
80
+ def _ticket_dir(repo_root: Path) -> Path:
81
+ repo_root = repo_root.resolve()
82
+ return repo_root / ".codex-autorunner" / "tickets"
83
+
84
+
85
+ def _require_flow_store(repo_root: Path) -> Optional[FlowStore]:
86
+ db_path, _ = _flow_paths(repo_root)
87
+ store = FlowStore(db_path)
88
+ try:
89
+ store.initialize()
90
+ return store
91
+ except Exception as exc:
92
+ _logger.warning("Flows database unavailable at %s: %s", db_path, exc)
93
+ return None
94
+
95
+
96
+ def _safe_list_flow_runs(
97
+ repo_root: Path, flow_type: Optional[str] = None, *, recover_stuck: bool = False
98
+ ) -> list[FlowRunRecord]:
99
+ db_path, _ = _flow_paths(repo_root)
100
+ store = FlowStore(db_path)
101
+ try:
102
+ store.initialize()
103
+ records = store.list_flow_runs(flow_type=flow_type)
104
+ if recover_stuck:
105
+ # Recover any flows stuck in active states with dead workers
106
+ records = [
107
+ reconcile_flow_run(repo_root, rec, store, logger=_logger)[0]
108
+ for rec in records
109
+ ]
110
+ return records
111
+ except Exception as exc:
112
+ _logger.debug("FlowStore list runs failed: %s", exc)
113
+ return []
114
+ finally:
115
+ try:
116
+ store.close()
117
+ except Exception:
118
+ pass
119
+
120
+
121
+ def _build_flow_definition(repo_root: Path, flow_type: str) -> FlowDefinition:
122
+ repo_root = repo_root.resolve()
123
+ key = (repo_root, flow_type)
124
+ if key in _definition_cache:
125
+ return _definition_cache[key]
126
+
127
+ if flow_type == "ticket_flow":
128
+ config = load_repo_config(repo_root)
129
+ engine = Engine(
130
+ repo_root,
131
+ config=config,
132
+ backend_factory=build_agent_backend_factory(repo_root, config),
133
+ app_server_supervisor_factory=build_app_server_supervisor_factory(config),
134
+ agent_id_validator=validate_agent_id,
135
+ )
136
+ agent_pool = AgentPool(engine.config)
137
+ definition = build_ticket_flow_definition(agent_pool=agent_pool)
138
+ else:
139
+ raise HTTPException(status_code=404, detail=f"Unknown flow type: {flow_type}")
140
+
141
+ definition.validate()
142
+ _definition_cache[key] = definition
143
+ return definition
144
+
145
+
146
+ def _get_flow_controller(repo_root: Path, flow_type: str) -> FlowController:
147
+ repo_root = repo_root.resolve()
148
+ key = (repo_root, flow_type)
149
+ if key in _controller_cache:
150
+ return _controller_cache[key]
151
+
152
+ db_path, artifacts_root = _flow_paths(repo_root)
153
+ definition = _build_flow_definition(repo_root, flow_type)
154
+
155
+ controller = FlowController(
156
+ definition=definition,
157
+ db_path=db_path,
158
+ artifacts_root=artifacts_root,
159
+ )
160
+ try:
161
+ controller.initialize()
162
+ except Exception as exc:
163
+ _logger.warning("Failed to initialize flow controller: %s", exc)
164
+ raise HTTPException(
165
+ status_code=503, detail="Flows unavailable; initialize the repo first."
166
+ ) from exc
167
+ _controller_cache[key] = controller
168
+ return controller
169
+
170
+
171
+ def _get_flow_record(repo_root: Path, run_id: str) -> FlowRunRecord:
172
+ store = _require_flow_store(repo_root)
173
+ if store is None:
174
+ raise HTTPException(status_code=503, detail="Flows database unavailable")
175
+ try:
176
+ record = store.get_flow_run(run_id)
177
+ finally:
178
+ try:
179
+ store.close()
180
+ except Exception:
181
+ pass
182
+ if not record:
183
+ raise HTTPException(status_code=404, detail=f"Flow run {run_id} not found")
184
+ return record
185
+
186
+
187
+ def _active_or_paused_run(records: list[FlowRunRecord]) -> Optional[FlowRunRecord]:
188
+ if not records:
189
+ return None
190
+ latest = records[0]
191
+ if latest.status in (FlowRunStatus.RUNNING, FlowRunStatus.PAUSED):
192
+ return latest
193
+ return None
194
+
195
+
196
+ def _normalize_run_id(run_id: Union[str, uuid.UUID]) -> str:
197
+ try:
198
+ return str(uuid.UUID(str(run_id)))
199
+ except ValueError:
200
+ raise HTTPException(status_code=400, detail="Invalid run_id") from None
201
+
202
+
203
+ def _cleanup_worker_handle(run_id: str) -> None:
204
+ handle = _active_workers.pop(run_id, None)
205
+ if not handle:
206
+ return
207
+
208
+ proc, stdout, stderr = handle
209
+ if proc and proc.poll() is None:
210
+ try:
211
+ proc.terminate()
212
+ except Exception:
213
+ pass
214
+
215
+ for stream in (stdout, stderr):
216
+ if stream and not stream.closed:
217
+ try:
218
+ stream.flush()
219
+ except Exception:
220
+ pass
221
+ try:
222
+ stream.close()
223
+ except Exception:
224
+ pass
225
+
226
+
227
+ def _reap_dead_worker(run_id: str) -> None:
228
+ handle = _active_workers.get(run_id)
229
+ if not handle:
230
+ return
231
+ proc, *_ = handle
232
+ if proc and proc.poll() is not None:
233
+ _cleanup_worker_handle(run_id)
234
+
235
+
236
+ class FlowStartRequest(BaseModel):
237
+ input_data: Dict = Field(default_factory=dict)
238
+ metadata: Optional[Dict] = None
239
+
240
+
241
+ class BootstrapCheckResponse(BaseModel):
242
+ status: str
243
+ github_available: Optional[bool] = None
244
+ repo: Optional[str] = None
245
+
246
+
247
+ class SeedIssueRequest(BaseModel):
248
+ issue_ref: Optional[str] = None # GitHub issue number, #num, or URL
249
+ plan_text: Optional[str] = None # Freeform plan text when GitHub unavailable
250
+
251
+
252
+ class FlowWorkerHealthResponse(BaseModel):
253
+ status: str
254
+ pid: Optional[int]
255
+ is_alive: bool
256
+ message: Optional[str] = None
257
+
258
+ @classmethod
259
+ def from_health(cls, health: FlowWorkerHealth) -> "FlowWorkerHealthResponse":
260
+ return cls(
261
+ status=health.status,
262
+ pid=health.pid,
263
+ is_alive=health.is_alive,
264
+ message=health.message,
265
+ )
266
+
267
+
268
+ class FlowStatusResponse(BaseModel):
269
+ id: str
270
+ flow_type: str
271
+ status: str
272
+ current_step: Optional[str]
273
+ created_at: str
274
+ started_at: Optional[str]
275
+ finished_at: Optional[str]
276
+ error_message: Optional[str]
277
+ state: Dict = Field(default_factory=dict)
278
+ reason_summary: Optional[str] = None
279
+ last_event_seq: Optional[int] = None
280
+ last_event_at: Optional[str] = None
281
+ worker_health: Optional[FlowWorkerHealthResponse] = None
282
+
283
+ @classmethod
284
+ def from_record(
285
+ cls,
286
+ record: FlowRunRecord,
287
+ *,
288
+ last_event_seq: Optional[int] = None,
289
+ last_event_at: Optional[str] = None,
290
+ worker_health: Optional[FlowWorkerHealth] = None,
291
+ ) -> "FlowStatusResponse":
292
+ state = record.state or {}
293
+ reason_summary = None
294
+ if isinstance(state, dict):
295
+ value = state.get("reason_summary")
296
+ if isinstance(value, str):
297
+ reason_summary = value
298
+ return cls(
299
+ id=record.id,
300
+ flow_type=record.flow_type,
301
+ status=record.status.value,
302
+ current_step=record.current_step,
303
+ created_at=record.created_at,
304
+ started_at=record.started_at,
305
+ finished_at=record.finished_at,
306
+ error_message=record.error_message,
307
+ state=state,
308
+ reason_summary=reason_summary,
309
+ last_event_seq=last_event_seq,
310
+ last_event_at=last_event_at,
311
+ worker_health=(
312
+ FlowWorkerHealthResponse.from_health(worker_health)
313
+ if worker_health
314
+ else None
315
+ ),
316
+ )
317
+
318
+
319
+ class FlowArtifactInfo(BaseModel):
320
+ id: str
321
+ kind: str
322
+ path: str
323
+ created_at: str
324
+ metadata: Dict = Field(default_factory=dict)
325
+
326
+
327
+ def _build_flow_status_response(
328
+ record: FlowRunRecord,
329
+ repo_root: Path,
330
+ *,
331
+ store: Optional[FlowStore] = None,
332
+ ) -> FlowStatusResponse:
333
+ snapshot = build_flow_status_snapshot(repo_root, record, store)
334
+ resp = FlowStatusResponse.from_record(
335
+ record,
336
+ last_event_seq=snapshot["last_event_seq"],
337
+ last_event_at=snapshot["last_event_at"],
338
+ worker_health=snapshot["worker_health"],
339
+ )
340
+ if snapshot.get("state") is not None:
341
+ resp.state = snapshot["state"]
342
+ return resp
343
+
344
+
345
+ def _start_flow_worker(repo_root: Path, run_id: str) -> Optional[subprocess.Popen]:
346
+ normalized_run_id = _normalize_run_id(run_id)
347
+
348
+ _reap_dead_worker(normalized_run_id)
349
+ result = ensure_worker(repo_root, normalized_run_id)
350
+ if result["status"] == "reused":
351
+ health = result["health"]
352
+ _logger.info(
353
+ "Worker already active for run %s (pid=%s), skipping spawn",
354
+ normalized_run_id,
355
+ health.pid,
356
+ )
357
+ return None
358
+ proc = result["proc"]
359
+ stdout_handle = result["stdout"]
360
+ stderr_handle = result["stderr"]
361
+ _active_workers[normalized_run_id] = (proc, stdout_handle, stderr_handle)
362
+ _logger.info("Started flow worker for run %s (pid=%d)", normalized_run_id, proc.pid)
363
+ return proc
364
+
365
+
366
+ def _stop_worker(run_id: str, timeout: float = 10.0) -> None:
367
+ normalized_run_id = _normalize_run_id(run_id)
368
+ handle = _active_workers.get(normalized_run_id)
369
+ if not handle:
370
+ health = check_worker_health(find_repo_root(), normalized_run_id)
371
+ if health.is_alive and health.pid:
372
+ try:
373
+ _logger.info(
374
+ "Stopping untracked worker for run %s (pid=%s)",
375
+ normalized_run_id,
376
+ health.pid,
377
+ )
378
+ subprocess.run(["kill", str(health.pid)], check=False)
379
+ except Exception as exc:
380
+ _logger.warning(
381
+ "Failed to stop untracked worker %s: %s", normalized_run_id, exc
382
+ )
383
+ return
384
+
385
+ proc, *_ = handle
386
+ if proc and proc.poll() is None:
387
+ proc.terminate()
388
+ try:
389
+ proc.wait(timeout=timeout)
390
+ except subprocess.TimeoutExpired:
391
+ _logger.warning(
392
+ "Worker for run %s did not exit in time, killing", normalized_run_id
393
+ )
394
+ proc.kill()
395
+ except Exception as exc:
396
+ _logger.warning("Error stopping worker %s: %s", normalized_run_id, exc)
397
+
398
+ _cleanup_worker_handle(normalized_run_id)
399
+
400
+
401
+ def build_flow_routes() -> APIRouter:
402
+ router = APIRouter(prefix="/api/flows", tags=["flows"])
403
+
404
+ def _definition_info(definition: FlowDefinition) -> Dict:
405
+ return {
406
+ "type": definition.flow_type,
407
+ "name": definition.name,
408
+ "description": definition.description,
409
+ "input_schema": definition.input_schema or {},
410
+ }
411
+
412
+ def _resolve_outbox_for_record(record: FlowRunRecord, repo_root: Path):
413
+ workspace_root = Path(record.input_data.get("workspace_root") or repo_root)
414
+ runs_dir = Path(record.input_data.get("runs_dir") or ".codex-autorunner/runs")
415
+ return resolve_outbox_paths(
416
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=record.id
417
+ )
418
+
419
+ @router.get("")
420
+ async def list_flow_definitions():
421
+ repo_root = find_repo_root()
422
+ definitions = [
423
+ _definition_info(_build_flow_definition(repo_root, flow_type))
424
+ for flow_type in _supported_flow_types
425
+ ]
426
+ return {"definitions": definitions}
427
+
428
+ @router.get("/runs", response_model=list[FlowStatusResponse])
429
+ async def list_runs(flow_type: Optional[str] = None, reconcile: bool = False):
430
+ repo_root = find_repo_root()
431
+ store = _require_flow_store(repo_root)
432
+ records: list[FlowRunRecord] = []
433
+ try:
434
+ if store:
435
+ records = store.list_flow_runs(flow_type=flow_type)
436
+ if reconcile:
437
+ records = [
438
+ reconcile_flow_run(repo_root, rec, store, logger=_logger)[0]
439
+ for rec in records
440
+ ]
441
+ else:
442
+ records = _safe_list_flow_runs(
443
+ repo_root, flow_type=flow_type, recover_stuck=reconcile
444
+ )
445
+ return [
446
+ _build_flow_status_response(rec, repo_root, store=store)
447
+ for rec in records
448
+ ]
449
+ finally:
450
+ if store:
451
+ store.close()
452
+
453
+ @router.get("/{flow_type}")
454
+ async def get_flow_definition(flow_type: str):
455
+ repo_root = find_repo_root()
456
+ if flow_type not in _supported_flow_types:
457
+ raise HTTPException(
458
+ status_code=404, detail=f"Unknown flow type: {flow_type}"
459
+ )
460
+ definition = _build_flow_definition(repo_root, flow_type)
461
+ return _definition_info(definition)
462
+
463
+ async def _start_flow(
464
+ flow_type: str, request: FlowStartRequest, *, force_new: bool = False
465
+ ) -> FlowStatusResponse:
466
+ if flow_type not in _supported_flow_types:
467
+ raise HTTPException(
468
+ status_code=404, detail=f"Unknown flow type: {flow_type}"
469
+ )
470
+
471
+ repo_root = find_repo_root()
472
+ controller = _get_flow_controller(repo_root, flow_type)
473
+
474
+ # Reuse an active/paused run unless force_new is requested.
475
+ if not force_new:
476
+ runs = _safe_list_flow_runs(
477
+ repo_root, flow_type=flow_type, recover_stuck=True
478
+ )
479
+ active = _active_or_paused_run(runs)
480
+ if active:
481
+ _reap_dead_worker(active.id)
482
+ _start_flow_worker(repo_root, active.id)
483
+ store = _require_flow_store(repo_root)
484
+ try:
485
+ response = _build_flow_status_response(
486
+ active, repo_root, store=store
487
+ )
488
+ finally:
489
+ if store:
490
+ store.close()
491
+ response.state = response.state or {}
492
+ response.state["hint"] = "active_run_reused"
493
+ return response
494
+
495
+ run_id = _normalize_run_id(uuid.uuid4())
496
+
497
+ record = await controller.start_flow(
498
+ input_data=request.input_data,
499
+ run_id=run_id,
500
+ metadata=request.metadata,
501
+ )
502
+
503
+ _start_flow_worker(repo_root, run_id)
504
+
505
+ store = _require_flow_store(repo_root)
506
+ try:
507
+ return _build_flow_status_response(record, repo_root, store=store)
508
+ finally:
509
+ if store:
510
+ store.close()
511
+
512
+ @router.post("/{flow_type}/start", response_model=FlowStatusResponse)
513
+ async def start_flow(flow_type: str, request: FlowStartRequest):
514
+ meta = request.metadata if isinstance(request.metadata, dict) else {}
515
+ force_new = bool(meta.get("force_new"))
516
+ return await _start_flow(flow_type, request, force_new=force_new)
517
+
518
+ @router.get("/ticket_flow/bootstrap-check", response_model=BootstrapCheckResponse)
519
+ async def bootstrap_check():
520
+ """
521
+ Determine whether ISSUE.md already exists and whether GitHub is available
522
+ for fetching an issue before bootstrapping the ticket flow.
523
+ """
524
+ repo_root = find_repo_root()
525
+ result = ux_bootstrap_check(repo_root, github_service_factory=GitHubService)
526
+ if result.status == "ready":
527
+ return BootstrapCheckResponse(status="ready")
528
+ return BootstrapCheckResponse(
529
+ status=result.status,
530
+ github_available=result.github_available,
531
+ repo=result.repo_slug,
532
+ )
533
+
534
+ @router.post("/ticket_flow/seed-issue")
535
+ async def seed_issue(request: SeedIssueRequest):
536
+ """Create .codex-autorunner/ISSUE.md from GitHub issue or user-provided text."""
537
+ repo_root = find_repo_root()
538
+ issue_path = issue_md_path(repo_root)
539
+ issue_path.parent.mkdir(parents=True, exist_ok=True)
540
+
541
+ # GitHub-backed path
542
+ if request.issue_ref:
543
+ try:
544
+ seed = seed_issue_from_github(
545
+ repo_root, request.issue_ref, github_service_factory=GitHubService
546
+ )
547
+ atomic_write(issue_path, seed.content)
548
+ return {
549
+ "status": "ok",
550
+ "source": "github",
551
+ "issue_number": seed.issue_number,
552
+ "repo": seed.repo_slug,
553
+ }
554
+ except GitHubError as exc:
555
+ raise HTTPException(
556
+ status_code=exc.status_code, detail=str(exc)
557
+ ) from exc
558
+ except RuntimeError as exc:
559
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
560
+ except Exception as exc: # pragma: no cover - defensive
561
+ _logger.exception("Failed to seed ISSUE.md from GitHub: %s", exc)
562
+ raise HTTPException(
563
+ status_code=500, detail="Failed to fetch issue from GitHub"
564
+ ) from exc
565
+
566
+ # Manual text path
567
+ if request.plan_text:
568
+ content = seed_issue_from_text(request.plan_text)
569
+ atomic_write(issue_path, content)
570
+ return {"status": "ok", "source": "user_input"}
571
+
572
+ raise HTTPException(
573
+ status_code=400,
574
+ detail="issue_ref or plan_text is required to seed ISSUE.md",
575
+ )
576
+
577
+ @router.post("/ticket_flow/bootstrap", response_model=FlowStatusResponse)
578
+ async def bootstrap_ticket_flow(request: Optional[FlowStartRequest] = None):
579
+ repo_root = find_repo_root()
580
+ ticket_dir = repo_root / ".codex-autorunner" / "tickets"
581
+ ticket_dir.mkdir(parents=True, exist_ok=True)
582
+ ticket_path = ticket_dir / "TICKET-001.md"
583
+ existing_tickets = list_ticket_paths(ticket_dir)
584
+ tickets_exist = bool(existing_tickets)
585
+ flow_request = request or FlowStartRequest()
586
+ meta = flow_request.metadata if isinstance(flow_request.metadata, dict) else {}
587
+ force_new = bool(meta.get("force_new"))
588
+
589
+ if not force_new:
590
+ records = _safe_list_flow_runs(
591
+ repo_root, flow_type="ticket_flow", recover_stuck=True
592
+ )
593
+ active = _active_or_paused_run(records)
594
+ if active:
595
+ _reap_dead_worker(active.id)
596
+ _start_flow_worker(repo_root, active.id)
597
+ store = _require_flow_store(repo_root)
598
+ try:
599
+ resp = _build_flow_status_response(active, repo_root, store=store)
600
+ finally:
601
+ if store:
602
+ store.close()
603
+ resp.state = resp.state or {}
604
+ resp.state["hint"] = "active_run_reused"
605
+ return resp
606
+
607
+ seeded = False
608
+ if not tickets_exist and not ticket_path.exists():
609
+ template = """---
610
+ agent: codex
611
+ done: false
612
+ title: Bootstrap ticket plan
613
+ goal: Capture scope and seed follow-up tickets
614
+ ---
615
+
616
+ You are the first ticket in a new ticket_flow run.
617
+
618
+ - Read `.codex-autorunner/ISSUE.md`. If it is missing:
619
+ - If GitHub is available, ask the user for the issue/PR URL or number and create `.codex-autorunner/ISSUE.md` from it.
620
+ - If GitHub is not available, write `DISPATCH.md` with `mode: pause` asking the user to describe the work (or share a doc). After the reply, create `.codex-autorunner/ISSUE.md` with their input.
621
+ - If helpful, create or update workspace docs under `.codex-autorunner/workspace/`:
622
+ - `active_context.md` for current context and links
623
+ - `decisions.md` for decisions/rationale
624
+ - `spec.md` for requirements and constraints
625
+ - Break the work into additional `TICKET-00X.md` files with clear owners/goals; keep this ticket open until they exist.
626
+ - Place any supporting artifacts in `.codex-autorunner/runs/<run_id>/dispatch/` if needed.
627
+ - Write `DISPATCH.md` to dispatch a message to the user:
628
+ - Use `mode: pause` (handoff) to wait for user response. This pauses execution.
629
+ - Use `mode: notify` (informational) to message the user but keep running.
630
+ """
631
+ ticket_path.write_text(template, encoding="utf-8")
632
+ seeded = True
633
+
634
+ meta = flow_request.metadata if isinstance(flow_request.metadata, dict) else {}
635
+ payload = FlowStartRequest(
636
+ input_data=flow_request.input_data,
637
+ metadata=meta | {"seeded_ticket": seeded},
638
+ )
639
+ return await _start_flow("ticket_flow", payload, force_new=force_new)
640
+
641
+ @router.get("/ticket_flow/tickets")
642
+ async def list_ticket_files():
643
+ repo_root = find_repo_root()
644
+ ticket_dir = repo_root / ".codex-autorunner" / "tickets"
645
+ tickets = []
646
+ for path in list_ticket_paths(ticket_dir):
647
+ doc, errors = read_ticket(path)
648
+ idx = getattr(doc, "index", None) or parse_ticket_index(path.name)
649
+ # When frontmatter is broken, still surface the raw ticket body so
650
+ # the user can inspect and fix the file in the UI instead of seeing
651
+ # an empty card.
652
+ try:
653
+ raw_body = path.read_text(encoding="utf-8")
654
+ _, parsed_body = parse_markdown_frontmatter(raw_body)
655
+ except Exception:
656
+ parsed_body = None
657
+ rel_path = safe_relpath(path, repo_root)
658
+ tickets.append(
659
+ {
660
+ "path": rel_path,
661
+ "index": idx,
662
+ "frontmatter": asdict(doc.frontmatter) if doc else None,
663
+ "body": doc.body if doc else parsed_body,
664
+ "errors": errors,
665
+ }
666
+ )
667
+ return {
668
+ "ticket_dir": safe_relpath(ticket_dir, repo_root),
669
+ "tickets": tickets,
670
+ }
671
+
672
+ @router.post("/ticket_flow/tickets", response_model=TicketResponse)
673
+ async def create_ticket(request: TicketCreateRequest):
674
+ """Create a new ticket with auto-generated index."""
675
+ repo_root = find_repo_root()
676
+ ticket_dir = repo_root / ".codex-autorunner" / "tickets"
677
+ ticket_dir.mkdir(parents=True, exist_ok=True)
678
+
679
+ # Find next available index
680
+ existing_paths = list_ticket_paths(ticket_dir)
681
+ existing_indices = set()
682
+ for p in existing_paths:
683
+ idx = parse_ticket_index(p.name)
684
+ if idx is not None:
685
+ existing_indices.add(idx)
686
+
687
+ next_index = 1
688
+ while next_index in existing_indices:
689
+ next_index += 1
690
+
691
+ # Build frontmatter
692
+ title_line = f"title: {request.title}\n" if request.title else ""
693
+ goal_line = f"goal: {request.goal}\n" if request.goal else ""
694
+
695
+ content = (
696
+ "---\n"
697
+ f"agent: {request.agent}\n"
698
+ "done: false\n"
699
+ f"{title_line}"
700
+ f"{goal_line}"
701
+ "---\n\n"
702
+ f"{request.body}\n"
703
+ )
704
+
705
+ ticket_path = ticket_dir / f"TICKET-{next_index:03d}.md"
706
+ atomic_write(ticket_path, content)
707
+
708
+ # Read back to validate and return
709
+ doc, errors = read_ticket(ticket_path)
710
+ if errors or not doc:
711
+ raise HTTPException(
712
+ status_code=400, detail=f"Failed to create valid ticket: {errors}"
713
+ )
714
+
715
+ return TicketResponse(
716
+ path=safe_relpath(ticket_path, repo_root),
717
+ index=doc.index,
718
+ frontmatter=asdict(doc.frontmatter),
719
+ body=doc.body,
720
+ )
721
+
722
+ @router.put("/ticket_flow/tickets/{index}", response_model=TicketResponse)
723
+ async def update_ticket(index: int, request: TicketUpdateRequest):
724
+ """Update an existing ticket by index."""
725
+ repo_root = find_repo_root()
726
+ ticket_dir = repo_root / ".codex-autorunner" / "tickets"
727
+ ticket_path = ticket_dir / f"TICKET-{index:03d}.md"
728
+
729
+ if not ticket_path.exists():
730
+ raise HTTPException(
731
+ status_code=404, detail=f"Ticket TICKET-{index:03d}.md not found"
732
+ )
733
+
734
+ # Validate frontmatter before saving
735
+ data, body = parse_markdown_frontmatter(request.content)
736
+ _, errors = lint_ticket_frontmatter(data)
737
+ if errors:
738
+ raise HTTPException(
739
+ status_code=400,
740
+ detail={"message": "Invalid ticket frontmatter", "errors": errors},
741
+ )
742
+
743
+ atomic_write(ticket_path, request.content)
744
+
745
+ # Read back to return validated data
746
+ doc, read_errors = read_ticket(ticket_path)
747
+ if read_errors or not doc:
748
+ raise HTTPException(
749
+ status_code=400, detail=f"Failed to save valid ticket: {read_errors}"
750
+ )
751
+
752
+ return TicketResponse(
753
+ path=safe_relpath(ticket_path, repo_root),
754
+ index=doc.index,
755
+ frontmatter=asdict(doc.frontmatter),
756
+ body=doc.body,
757
+ )
758
+
759
+ @router.delete("/ticket_flow/tickets/{index}", response_model=TicketDeleteResponse)
760
+ async def delete_ticket(index: int):
761
+ """Delete a ticket by index."""
762
+ repo_root = find_repo_root()
763
+ ticket_dir = repo_root / ".codex-autorunner" / "tickets"
764
+ ticket_path = ticket_dir / f"TICKET-{index:03d}.md"
765
+
766
+ if not ticket_path.exists():
767
+ raise HTTPException(
768
+ status_code=404, detail=f"Ticket TICKET-{index:03d}.md not found"
769
+ )
770
+
771
+ rel_path = safe_relpath(ticket_path, repo_root)
772
+ ticket_path.unlink()
773
+
774
+ return TicketDeleteResponse(
775
+ status="deleted",
776
+ index=index,
777
+ path=rel_path,
778
+ )
779
+
780
+ @router.post("/{run_id}/stop", response_model=FlowStatusResponse)
781
+ async def stop_flow(run_id: uuid.UUID):
782
+ run_id = _normalize_run_id(run_id)
783
+ repo_root = find_repo_root()
784
+ record = _get_flow_record(repo_root, run_id)
785
+ controller = _get_flow_controller(repo_root, record.flow_type)
786
+
787
+ _stop_worker(run_id)
788
+
789
+ updated = await controller.stop_flow(run_id)
790
+ store = _require_flow_store(repo_root)
791
+ try:
792
+ return _build_flow_status_response(updated, repo_root, store=store)
793
+ finally:
794
+ if store:
795
+ store.close()
796
+
797
+ @router.post("/{run_id}/resume", response_model=FlowStatusResponse)
798
+ async def resume_flow(run_id: uuid.UUID):
799
+ run_id = _normalize_run_id(run_id)
800
+ repo_root = find_repo_root()
801
+ record = _get_flow_record(repo_root, run_id)
802
+ controller = _get_flow_controller(repo_root, record.flow_type)
803
+
804
+ updated = await controller.resume_flow(run_id)
805
+ _reap_dead_worker(run_id)
806
+ _start_flow_worker(repo_root, run_id)
807
+
808
+ store = _require_flow_store(repo_root)
809
+ try:
810
+ return _build_flow_status_response(updated, repo_root, store=store)
811
+ finally:
812
+ if store:
813
+ store.close()
814
+
815
+ @router.post("/{run_id}/reconcile", response_model=FlowStatusResponse)
816
+ async def reconcile_flow(run_id: uuid.UUID):
817
+ run_id = _normalize_run_id(run_id)
818
+ repo_root = find_repo_root()
819
+ record = _get_flow_record(repo_root, run_id)
820
+ store = _require_flow_store(repo_root)
821
+ if not store:
822
+ raise HTTPException(status_code=503, detail="Flow store unavailable")
823
+ try:
824
+ record = reconcile_flow_run(repo_root, record, store, logger=_logger)[0]
825
+ return _build_flow_status_response(record, repo_root, store=store)
826
+ finally:
827
+ store.close()
828
+
829
+ @router.post("/{run_id}/archive")
830
+ async def archive_flow(
831
+ run_id: uuid.UUID, delete_run: bool = True, force: bool = False
832
+ ):
833
+ """Archive a completed flow by moving tickets to the run's artifact directory.
834
+
835
+ Args:
836
+ run_id: The flow run to archive.
837
+ delete_run: Whether to delete the run record after archiving.
838
+ force: If True, allow archiving flows stuck in stopping/paused state
839
+ by force-stopping the worker first.
840
+ """
841
+ run_id = _normalize_run_id(run_id)
842
+ repo_root = find_repo_root()
843
+ record = _get_flow_record(repo_root, run_id)
844
+
845
+ # Allow archiving terminal flows, or force-archiving stuck flows
846
+ if not FlowRunStatus(record.status).is_terminal():
847
+ if force and record.status in (
848
+ FlowRunStatus.STOPPING,
849
+ FlowRunStatus.PAUSED,
850
+ ):
851
+ # Force-stop any remaining worker before archiving
852
+ _stop_worker(run_id, timeout=2.0)
853
+ _logger.info(
854
+ "Force-archiving flow %s in %s state", run_id, record.status.value
855
+ )
856
+ else:
857
+ raise HTTPException(
858
+ status_code=400,
859
+ detail="Can only archive completed/stopped/failed flows (use force=true for stuck flows)",
860
+ )
861
+
862
+ # Move tickets to run artifacts directory
863
+ _, artifacts_root = _flow_paths(repo_root)
864
+ archive_dir = artifacts_root / run_id / "archived_tickets"
865
+ archive_dir.mkdir(parents=True, exist_ok=True)
866
+
867
+ ticket_dir = repo_root / ".codex-autorunner" / "tickets"
868
+ archived_count = 0
869
+ for ticket_path in list_ticket_paths(ticket_dir):
870
+ dest = archive_dir / ticket_path.name
871
+ shutil.move(str(ticket_path), str(dest))
872
+ archived_count += 1
873
+
874
+ # Archive runs directory (dispatch_history, reply_history, etc.) to dismiss notifications
875
+ outbox_paths = _resolve_outbox_for_record(record, repo_root)
876
+ run_dir = outbox_paths.run_dir
877
+ if run_dir.exists() and run_dir.is_dir():
878
+ archived_runs_dir = artifacts_root / run_id / "archived_runs"
879
+ shutil.move(str(run_dir), str(archived_runs_dir))
880
+
881
+ # Delete run record if requested
882
+ if delete_run:
883
+ store = _require_flow_store(repo_root)
884
+ if store:
885
+ store.delete_flow_run(run_id)
886
+ store.close()
887
+
888
+ return {
889
+ "status": "archived",
890
+ "run_id": run_id,
891
+ "tickets_archived": archived_count,
892
+ }
893
+
894
+ @router.get("/{run_id}/status", response_model=FlowStatusResponse)
895
+ async def get_flow_status(run_id: uuid.UUID, reconcile: bool = False):
896
+ run_id = _normalize_run_id(run_id)
897
+ repo_root = find_repo_root()
898
+
899
+ _reap_dead_worker(run_id)
900
+
901
+ record = _get_flow_record(repo_root, run_id)
902
+ store = _require_flow_store(repo_root)
903
+ try:
904
+ if reconcile and store:
905
+ record = reconcile_flow_run(repo_root, record, store, logger=_logger)[0]
906
+ return _build_flow_status_response(record, repo_root, store=store)
907
+ finally:
908
+ if store:
909
+ store.close()
910
+
911
+ @router.get("/{run_id}/events")
912
+ async def stream_flow_events(
913
+ run_id: uuid.UUID, request: Request, after: Optional[int] = None
914
+ ):
915
+ run_id = _normalize_run_id(run_id)
916
+ repo_root = find_repo_root()
917
+ record = _get_flow_record(repo_root, run_id)
918
+ controller = _get_flow_controller(repo_root, record.flow_type)
919
+
920
+ async def event_stream():
921
+ try:
922
+ resume_after = after
923
+ if resume_after is None:
924
+ last_event_id = request.headers.get("Last-Event-ID")
925
+ if last_event_id:
926
+ try:
927
+ resume_after = int(last_event_id)
928
+ except ValueError:
929
+ _logger.debug(
930
+ "Invalid Last-Event-ID %s for run %s",
931
+ last_event_id,
932
+ run_id,
933
+ )
934
+ async for event in controller.stream_events(
935
+ run_id, after_seq=resume_after
936
+ ):
937
+ data = event.model_dump(mode="json")
938
+ yield f"id: {event.seq}\n" f"data: {json.dumps(data)}\n\n"
939
+ except Exception as e:
940
+ _logger.exception("Error streaming events for run %s: %s", run_id, e)
941
+ raise
942
+
943
+ return StreamingResponse(
944
+ event_stream(),
945
+ media_type="text/event-stream",
946
+ headers={
947
+ "Cache-Control": "no-cache",
948
+ "Connection": "keep-alive",
949
+ "X-Accel-Buffering": "no",
950
+ },
951
+ )
952
+
953
+ @router.get("/{run_id}/dispatch_history")
954
+ async def get_dispatch_history(run_id: str):
955
+ """Get dispatch history for a flow run.
956
+
957
+ Returns all dispatches (agent->human communications) for this run.
958
+ """
959
+ normalized = _normalize_run_id(run_id)
960
+ repo_root = find_repo_root()
961
+ record = _get_flow_record(repo_root, normalized)
962
+ paths = _resolve_outbox_for_record(record, repo_root)
963
+
964
+ history_entries = []
965
+ history_dir = paths.dispatch_history_dir
966
+ if history_dir.exists() and history_dir.is_dir():
967
+ for entry in sorted(
968
+ [p for p in history_dir.iterdir() if p.is_dir()],
969
+ key=lambda p: p.name,
970
+ reverse=True,
971
+ ):
972
+ dispatch_path = entry / "DISPATCH.md"
973
+ dispatch, errors = (
974
+ parse_dispatch(dispatch_path)
975
+ if dispatch_path.exists()
976
+ else (None, ["Dispatch file missing"])
977
+ )
978
+ dispatch_dict = asdict(dispatch) if dispatch else None
979
+ if dispatch_dict and dispatch:
980
+ dispatch_dict["is_handoff"] = dispatch.is_handoff
981
+ attachments = []
982
+ for child in sorted(entry.rglob("*")):
983
+ if child.name == "DISPATCH.md":
984
+ continue
985
+ rel = child.relative_to(entry).as_posix()
986
+ if any(part.startswith(".") for part in Path(rel).parts):
987
+ continue
988
+ if child.is_dir():
989
+ continue
990
+ attachments.append(
991
+ {
992
+ "name": child.name,
993
+ "rel_path": rel,
994
+ "path": safe_relpath(child, repo_root),
995
+ "size": child.stat().st_size if child.is_file() else None,
996
+ "url": f"api/flows/{normalized}/dispatch_history/{entry.name}/{quote(rel)}",
997
+ }
998
+ )
999
+ history_entries.append(
1000
+ {
1001
+ "seq": entry.name,
1002
+ "dispatch": dispatch_dict,
1003
+ "errors": errors,
1004
+ "attachments": attachments,
1005
+ "path": safe_relpath(entry, repo_root),
1006
+ }
1007
+ )
1008
+
1009
+ return {"run_id": normalized, "history": history_entries}
1010
+
1011
+ @router.get("/{run_id}/reply_history/{seq}/{file_path:path}")
1012
+ def get_reply_history_file(run_id: str, seq: str, file_path: str):
1013
+ repo_root = find_repo_root()
1014
+ db_path, _ = _flow_paths(repo_root)
1015
+ store = FlowStore(db_path)
1016
+ try:
1017
+ store.initialize()
1018
+ record = store.get_flow_run(run_id)
1019
+ finally:
1020
+ try:
1021
+ store.close()
1022
+ except Exception:
1023
+ pass
1024
+ if not record:
1025
+ raise HTTPException(status_code=404, detail="Run not found")
1026
+
1027
+ if not (len(seq) == 4 and seq.isdigit()):
1028
+ raise HTTPException(status_code=400, detail="Invalid seq")
1029
+ if ".." in file_path or file_path.startswith("/"):
1030
+ raise HTTPException(status_code=400, detail="Invalid file path")
1031
+ filename = os.path.basename(file_path)
1032
+ if filename != file_path:
1033
+ raise HTTPException(status_code=400, detail="Invalid file path")
1034
+
1035
+ input_data = dict(record.input_data or {})
1036
+ workspace_root = Path(input_data.get("workspace_root") or repo_root)
1037
+ runs_dir = Path(input_data.get("runs_dir") or ".codex-autorunner/runs")
1038
+ from ....tickets.replies import resolve_reply_paths
1039
+
1040
+ reply_paths = resolve_reply_paths(
1041
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
1042
+ )
1043
+ target = reply_paths.reply_history_dir / seq / filename
1044
+ if not target.exists() or not target.is_file():
1045
+ raise HTTPException(status_code=404, detail="File not found")
1046
+ return FileResponse(path=str(target), filename=filename)
1047
+
1048
+ @router.get("/{run_id}/dispatch_history/{seq}/{file_path:path}")
1049
+ async def get_dispatch_file(run_id: str, seq: str, file_path: str):
1050
+ """Get an attachment file from a dispatch history entry."""
1051
+ normalized = _normalize_run_id(run_id)
1052
+ repo_root = find_repo_root()
1053
+ record = _get_flow_record(repo_root, normalized)
1054
+ paths = _resolve_outbox_for_record(record, repo_root)
1055
+
1056
+ base_history = paths.dispatch_history_dir.resolve()
1057
+
1058
+ seq_clean = seq.strip()
1059
+ if not re.fullmatch(r"[0-9]{4}", seq_clean):
1060
+ raise HTTPException(
1061
+ status_code=400, detail="Invalid dispatch history sequence"
1062
+ )
1063
+
1064
+ history_dir = (base_history / seq_clean).resolve()
1065
+ if not history_dir.is_relative_to(base_history) or not history_dir.is_dir():
1066
+ raise HTTPException(
1067
+ status_code=404, detail=f"Dispatch history not found for run {run_id}"
1068
+ )
1069
+
1070
+ file_rel = PurePosixPath(file_path)
1071
+ if file_rel.is_absolute() or ".." in file_rel.parts or "\\" in file_path:
1072
+ raise HTTPException(status_code=400, detail="Invalid dispatch file path")
1073
+
1074
+ safe_parts = [part for part in file_rel.parts if part not in {"", "."}]
1075
+ if any(not re.fullmatch(r"[A-Za-z0-9._-]+", part) for part in safe_parts):
1076
+ raise HTTPException(status_code=400, detail="Invalid dispatch file path")
1077
+
1078
+ target = (history_dir / Path(*safe_parts)).resolve()
1079
+ try:
1080
+ resolved = target.resolve()
1081
+ except OSError as exc:
1082
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
1083
+
1084
+ if not resolved.exists():
1085
+ raise HTTPException(status_code=404, detail="File not found")
1086
+
1087
+ if not resolved.is_relative_to(history_dir):
1088
+ raise HTTPException(
1089
+ status_code=403,
1090
+ detail="Access denied: file outside dispatch history directory",
1091
+ )
1092
+
1093
+ return FileResponse(resolved, filename=resolved.name)
1094
+
1095
+ @router.get("/{run_id}/artifacts", response_model=list[FlowArtifactInfo])
1096
+ async def list_flow_artifacts(run_id: str):
1097
+ normalized = _normalize_run_id(run_id)
1098
+ repo_root = find_repo_root()
1099
+ record = _get_flow_record(repo_root, normalized)
1100
+ controller = _get_flow_controller(repo_root, record.flow_type)
1101
+
1102
+ artifacts = controller.get_artifacts(normalized)
1103
+ return [
1104
+ FlowArtifactInfo(
1105
+ id=art.id,
1106
+ kind=art.kind,
1107
+ path=art.path,
1108
+ created_at=art.created_at,
1109
+ metadata=art.metadata,
1110
+ )
1111
+ for art in artifacts
1112
+ ]
1113
+
1114
+ @router.get("/{run_id}/artifact")
1115
+ async def get_flow_artifact(run_id: str, kind: Optional[str] = None):
1116
+ normalized = _normalize_run_id(run_id)
1117
+ repo_root = find_repo_root()
1118
+ record = _get_flow_record(repo_root, normalized)
1119
+ controller = _get_flow_controller(repo_root, record.flow_type)
1120
+
1121
+ artifacts_root = controller.get_artifacts_dir(normalized)
1122
+ if not artifacts_root:
1123
+ from fastapi import HTTPException
1124
+
1125
+ raise HTTPException(
1126
+ status_code=404, detail=f"Artifact directory not found for run {run_id}"
1127
+ )
1128
+
1129
+ artifacts = controller.get_artifacts(normalized)
1130
+
1131
+ if kind:
1132
+ matching = [a for a in artifacts if a.kind == kind]
1133
+ else:
1134
+ matching = artifacts
1135
+
1136
+ if not matching:
1137
+ from fastapi import HTTPException
1138
+
1139
+ raise HTTPException(
1140
+ status_code=404,
1141
+ detail=f"No artifact found for run {run_id} with kind={kind}",
1142
+ )
1143
+
1144
+ artifact = matching[0]
1145
+ artifact_path = Path(artifact.path)
1146
+
1147
+ if not artifact_path.exists():
1148
+ from fastapi import HTTPException
1149
+
1150
+ raise HTTPException(
1151
+ status_code=404, detail=f"Artifact file not found: {artifact.path}"
1152
+ )
1153
+
1154
+ if not artifact_path.resolve().is_relative_to(artifacts_root.resolve()):
1155
+ from fastapi import HTTPException
1156
+
1157
+ raise HTTPException(
1158
+ status_code=403,
1159
+ detail="Access denied: artifact path outside run directory",
1160
+ )
1161
+
1162
+ return FileResponse(artifact_path, filename=artifact_path.name)
1163
+
1164
+ return router