codex-autorunner 1.0.0__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,490 @@
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.filebox import ensure_structure, save_file
31
+ from ....core.flows.models import FlowRunRecord, FlowRunStatus
32
+ from ....core.flows.store import FlowStore
33
+ from ....core.utils import find_repo_root
34
+ from ....tickets.files import safe_relpath
35
+ from ....tickets.outbox import parse_dispatch, resolve_outbox_paths
36
+ from ....tickets.replies import (
37
+ dispatch_reply,
38
+ ensure_reply_dirs,
39
+ next_reply_seq,
40
+ parse_user_reply,
41
+ resolve_reply_paths,
42
+ )
43
+
44
+ _logger = logging.getLogger(__name__)
45
+
46
+
47
+ def _flows_db_path(repo_root: Path) -> Path:
48
+ return repo_root / ".codex-autorunner" / "flows.db"
49
+
50
+
51
+ def _resolve_workspace_and_runs(
52
+ record_input: dict[str, Any], repo_root: Path
53
+ ) -> tuple[Path, Path]:
54
+ """
55
+ Normalize workspace_root/runs_dir with sensible fallbacks.
56
+
57
+ - workspace_root defaults to the current repo_root.
58
+ - runs_dir defaults to .codex-autorunner/runs.
59
+ - If runs_dir is absolute, keep it as-is; otherwise join to workspace_root.
60
+ """
61
+
62
+ raw_workspace = record_input.get("workspace_root")
63
+ workspace_root = Path(raw_workspace) if raw_workspace else repo_root
64
+ if not workspace_root.is_absolute():
65
+ workspace_root = (repo_root / workspace_root).resolve()
66
+ else:
67
+ workspace_root = workspace_root.resolve()
68
+
69
+ runs_dir_raw = record_input.get("runs_dir") or ".codex-autorunner/runs"
70
+ runs_dir_path = Path(runs_dir_raw)
71
+ if not runs_dir_path.is_absolute():
72
+ runs_dir_path = (workspace_root / runs_dir_path).resolve()
73
+ return workspace_root, runs_dir_path
74
+
75
+
76
+ def _timestamp(path: Path) -> Optional[str]:
77
+ try:
78
+ return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat()
79
+ except OSError:
80
+ return None
81
+
82
+
83
+ def _safe_attachment_name(name: str) -> str:
84
+ base = os.path.basename(name or "").strip()
85
+ if not base:
86
+ raise ValueError("Missing attachment filename")
87
+ if base.lower() == "user_reply.md":
88
+ raise ValueError("Attachment filename reserved: USER_REPLY.md")
89
+ if not re.fullmatch(r"[A-Za-z0-9._-]+", base):
90
+ raise ValueError(
91
+ "Invalid attachment filename; use only letters, digits, dot, underscore, dash"
92
+ )
93
+ return base
94
+
95
+
96
+ def _iter_seq_dirs(history_dir: Path) -> list[tuple[int, Path]]:
97
+ if not history_dir.exists() or not history_dir.is_dir():
98
+ return []
99
+ out: list[tuple[int, Path]] = []
100
+ try:
101
+ for child in history_dir.iterdir():
102
+ try:
103
+ if not child.is_dir():
104
+ continue
105
+ name = child.name
106
+ if not (len(name) == 4 and name.isdigit()):
107
+ continue
108
+ out.append((int(name), child))
109
+ except OSError:
110
+ continue
111
+ except OSError:
112
+ return []
113
+ out.sort(key=lambda x: x[0])
114
+ return out
115
+
116
+
117
+ def _collect_dispatch_history(
118
+ *, repo_root: Path, run_id: str, record_input: dict[str, Any]
119
+ ) -> list[dict[str, Any]]:
120
+ """Collect all dispatches from the dispatch history directory."""
121
+ workspace_root, runs_dir = _resolve_workspace_and_runs(record_input, repo_root)
122
+ outbox_paths = resolve_outbox_paths(
123
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
124
+ )
125
+ history: list[dict[str, Any]] = []
126
+ for seq, entry_dir in reversed(_iter_seq_dirs(outbox_paths.dispatch_history_dir)):
127
+ dispatch_path = entry_dir / "DISPATCH.md"
128
+ dispatch, errors = parse_dispatch(dispatch_path)
129
+ files: list[dict[str, str]] = []
130
+ try:
131
+ for child in sorted(entry_dir.iterdir(), key=lambda p: p.name):
132
+ try:
133
+ if child.name.startswith("."):
134
+ continue
135
+ if child.name == "DISPATCH.md":
136
+ continue
137
+ if child.is_dir():
138
+ continue
139
+ rel = child.name
140
+ url = f"api/flows/{run_id}/dispatch_history/{seq:04d}/{quote(rel)}"
141
+ size = None
142
+ try:
143
+ size = child.stat().st_size
144
+ except OSError:
145
+ size = None
146
+ files.append({"name": child.name, "url": url, "size": size})
147
+ except OSError:
148
+ continue
149
+ except OSError:
150
+ files = []
151
+ created_at = _timestamp(dispatch_path) or _timestamp(entry_dir)
152
+ history.append(
153
+ {
154
+ "seq": seq,
155
+ "dir": safe_relpath(entry_dir, workspace_root),
156
+ "created_at": created_at,
157
+ "dispatch": (
158
+ {
159
+ "mode": dispatch.mode,
160
+ "title": dispatch.title,
161
+ "body": dispatch.body,
162
+ "extra": dispatch.extra,
163
+ "is_handoff": dispatch.is_handoff,
164
+ }
165
+ if dispatch
166
+ else None
167
+ ),
168
+ "errors": errors,
169
+ "files": files,
170
+ }
171
+ )
172
+ return history
173
+
174
+
175
+ def _collect_reply_history(
176
+ *, repo_root: Path, run_id: str, record_input: dict[str, Any]
177
+ ):
178
+ workspace_root, runs_dir = _resolve_workspace_and_runs(record_input, repo_root)
179
+ reply_paths = resolve_reply_paths(
180
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
181
+ )
182
+ history: list[dict[str, Any]] = []
183
+ for seq, entry_dir in reversed(_iter_seq_dirs(reply_paths.reply_history_dir)):
184
+ reply_path = entry_dir / "USER_REPLY.md"
185
+ reply, errors = (
186
+ parse_user_reply(reply_path)
187
+ if reply_path.exists()
188
+ else (None, ["USER_REPLY.md missing"])
189
+ )
190
+ files: list[dict[str, str]] = []
191
+ try:
192
+ for child in sorted(entry_dir.iterdir(), key=lambda p: p.name):
193
+ try:
194
+ if child.name.startswith("."):
195
+ continue
196
+ if child.name == "USER_REPLY.md":
197
+ continue
198
+ if child.is_dir():
199
+ continue
200
+ rel = child.name
201
+ url = f"api/flows/{run_id}/reply_history/{seq:04d}/{quote(rel)}"
202
+ size = None
203
+ try:
204
+ size = child.stat().st_size
205
+ except OSError:
206
+ size = None
207
+ files.append({"name": child.name, "url": url, "size": size})
208
+ except OSError:
209
+ continue
210
+ except OSError:
211
+ files = []
212
+ created_at = _timestamp(reply_path) or _timestamp(entry_dir)
213
+ history.append(
214
+ {
215
+ "seq": seq,
216
+ "dir": safe_relpath(entry_dir, workspace_root),
217
+ "created_at": created_at,
218
+ "reply": (
219
+ {"title": reply.title, "body": reply.body, "extra": reply.extra}
220
+ if reply
221
+ else None
222
+ ),
223
+ "errors": errors,
224
+ "files": files,
225
+ }
226
+ )
227
+ return history
228
+
229
+
230
+ def _ticket_state_snapshot(record: FlowRunRecord) -> dict[str, Any]:
231
+ state = record.state if isinstance(record.state, dict) else {}
232
+ ticket_state = state.get("ticket_engine") if isinstance(state, dict) else {}
233
+ if not isinstance(ticket_state, dict):
234
+ ticket_state = {}
235
+ allowed_keys = {
236
+ "current_ticket",
237
+ "total_turns",
238
+ "ticket_turns",
239
+ "dispatch_seq",
240
+ "reply_seq",
241
+ "reason",
242
+ "status",
243
+ }
244
+ return {k: ticket_state.get(k) for k in allowed_keys if k in ticket_state}
245
+
246
+
247
+ def build_messages_routes() -> APIRouter:
248
+ router = APIRouter()
249
+
250
+ @router.get("/api/messages/active")
251
+ def get_active_message(request: Request):
252
+ from ....core.config import load_repo_config
253
+
254
+ repo_root = find_repo_root()
255
+ db_path = _flows_db_path(repo_root)
256
+ if not db_path.exists():
257
+ return {"active": False}
258
+ try:
259
+ with FlowStore(
260
+ db_path, durable=load_repo_config(repo_root).durable_writes
261
+ ) as store:
262
+ paused = store.list_flow_runs(
263
+ flow_type="ticket_flow", status=FlowRunStatus.PAUSED
264
+ )
265
+ except Exception:
266
+ # Corrupt flows db should not 500 the UI.
267
+ return {"active": False}
268
+ if not paused:
269
+ return {"active": False}
270
+
271
+ # Walk paused runs (newest first as returned by FlowStore) until we find
272
+ # one with at least one archived dispatch. This avoids hiding
273
+ # older paused runs that do have history when the newest paused run
274
+ # hasn't yet written DISPATCH.md.
275
+ for record in paused:
276
+ history = _collect_dispatch_history(
277
+ repo_root=repo_root,
278
+ run_id=str(record.id),
279
+ record_input=dict(record.input_data or {}),
280
+ )
281
+ if not history:
282
+ continue
283
+ latest = history[0]
284
+ return {
285
+ "active": True,
286
+ "run_id": record.id,
287
+ "flow_type": record.flow_type,
288
+ "status": record.status.value,
289
+ "seq": latest.get("seq"),
290
+ "dispatch": latest.get("dispatch"),
291
+ "files": latest.get("files"),
292
+ "open_url": f"?tab=inbox&run_id={record.id}",
293
+ }
294
+
295
+ return {"active": False}
296
+
297
+ @router.get("/api/messages/threads")
298
+ def list_threads():
299
+ from ....core.config import load_repo_config
300
+
301
+ repo_root = find_repo_root()
302
+ db_path = _flows_db_path(repo_root)
303
+ if not db_path.exists():
304
+ return {"conversations": []}
305
+ try:
306
+ with FlowStore(
307
+ db_path, durable=load_repo_config(repo_root).durable_writes
308
+ ) as store:
309
+ runs = store.list_flow_runs(flow_type="ticket_flow")
310
+ except Exception:
311
+ return {"conversations": []}
312
+
313
+ conversations: list[dict[str, Any]] = []
314
+ for record in runs:
315
+ record_input = dict(record.input_data or {})
316
+ dispatch_history = _collect_dispatch_history(
317
+ repo_root=repo_root,
318
+ run_id=str(record.id),
319
+ record_input=record_input,
320
+ )
321
+ if not dispatch_history:
322
+ continue
323
+ latest = dispatch_history[0]
324
+ reply_history = _collect_reply_history(
325
+ repo_root=repo_root,
326
+ run_id=str(record.id),
327
+ record_input=record_input,
328
+ )
329
+ conversations.append(
330
+ {
331
+ "run_id": record.id,
332
+ "flow_type": record.flow_type,
333
+ "status": record.status.value,
334
+ "created_at": record.created_at,
335
+ "started_at": record.started_at,
336
+ "finished_at": record.finished_at,
337
+ "current_step": record.current_step,
338
+ "latest": latest,
339
+ "dispatch_count": len(dispatch_history),
340
+ "reply_count": len(reply_history),
341
+ "ticket_state": _ticket_state_snapshot(record),
342
+ "open_url": f"?tab=inbox&run_id={record.id}",
343
+ }
344
+ )
345
+ return {"conversations": conversations}
346
+
347
+ @router.get("/api/messages/threads/{run_id}")
348
+ def get_thread(run_id: str):
349
+ from ....core.config import load_repo_config
350
+
351
+ repo_root = find_repo_root()
352
+ db_path = _flows_db_path(repo_root)
353
+ empty_response = {
354
+ "dispatch_history": [],
355
+ "reply_history": [],
356
+ "dispatch_count": 0,
357
+ "reply_count": 0,
358
+ }
359
+ if not db_path.exists():
360
+ return empty_response
361
+ try:
362
+ with FlowStore(
363
+ db_path, durable=load_repo_config(repo_root).durable_writes
364
+ ) as store:
365
+ record = store.get_flow_run(run_id)
366
+ except Exception:
367
+ raise HTTPException(
368
+ status_code=404, detail="Flows database unavailable"
369
+ ) from None
370
+ if not record:
371
+ return empty_response
372
+ input_data = dict(record.input_data or {})
373
+ dispatch_history = _collect_dispatch_history(
374
+ repo_root=repo_root, run_id=run_id, record_input=input_data
375
+ )
376
+ reply_history = _collect_reply_history(
377
+ repo_root=repo_root, run_id=run_id, record_input=input_data
378
+ )
379
+ return {
380
+ "run": {
381
+ "id": record.id,
382
+ "flow_type": record.flow_type,
383
+ "status": record.status.value,
384
+ "created_at": record.created_at,
385
+ "started_at": record.started_at,
386
+ "finished_at": record.finished_at,
387
+ "current_step": record.current_step,
388
+ "error_message": record.error_message,
389
+ },
390
+ "dispatch_history": dispatch_history,
391
+ "reply_history": reply_history,
392
+ "dispatch_count": len(dispatch_history),
393
+ "reply_count": len(reply_history),
394
+ "ticket_state": _ticket_state_snapshot(record),
395
+ }
396
+
397
+ @router.post("/api/messages/{run_id}/reply")
398
+ async def post_reply(
399
+ run_id: str,
400
+ body: str = Form(""),
401
+ title: Optional[str] = Form(None),
402
+ # NOTE: FastAPI/starlette will supply either a single UploadFile or a list
403
+ # depending on how is multipart form is encoded. Declaring this as a
404
+ # concrete list avoids a common 422 where a single file upload is treated
405
+ # as a non-list value.
406
+ files: list[UploadFile] = File(default=[]), # noqa: B006,B008
407
+ ):
408
+ from ....core.config import load_repo_config
409
+
410
+ repo_root = find_repo_root()
411
+ db_path = _flows_db_path(repo_root)
412
+ if not db_path.exists():
413
+ raise HTTPException(status_code=404, detail="No flows database")
414
+ try:
415
+ with FlowStore(
416
+ db_path, durable=load_repo_config(repo_root).durable_writes
417
+ ) as store:
418
+ record = store.get_flow_run(run_id)
419
+ except Exception:
420
+ raise HTTPException(
421
+ status_code=404, detail="Flows database unavailable"
422
+ ) from None
423
+ if not record:
424
+ raise HTTPException(status_code=404, detail="Run not found")
425
+
426
+ input_data = dict(record.input_data or {})
427
+ workspace_root, runs_dir = _resolve_workspace_and_runs(input_data, repo_root)
428
+ reply_paths = resolve_reply_paths(
429
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
430
+ )
431
+ ensure_reply_dirs(reply_paths)
432
+
433
+ cleaned_title = (
434
+ title.strip() if isinstance(title, str) and title.strip() else None
435
+ )
436
+ cleaned_body = body or ""
437
+
438
+ if cleaned_title:
439
+ fm = yaml.safe_dump({"title": cleaned_title}, sort_keys=False).strip()
440
+ raw = f"---\n{fm}\n---\n\n{cleaned_body}\n"
441
+ else:
442
+ raw = cleaned_body
443
+ if raw and not raw.endswith("\n"):
444
+ raw += "\n"
445
+
446
+ try:
447
+ reply_paths.user_reply_path.parent.mkdir(parents=True, exist_ok=True)
448
+ reply_paths.user_reply_path.write_text(raw, encoding="utf-8")
449
+ except OSError as exc:
450
+ raise HTTPException(
451
+ status_code=500, detail=f"Failed to write USER_REPLY.md: {exc}"
452
+ ) from exc
453
+
454
+ for upload in files:
455
+ try:
456
+ filename = _safe_attachment_name(upload.filename or "")
457
+ except ValueError as exc:
458
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
459
+ dest = reply_paths.reply_dir / filename
460
+ data = await upload.read()
461
+ try:
462
+ dest.write_bytes(data)
463
+ try:
464
+ ensure_structure(repo_root)
465
+ save_file(repo_root, "inbox", filename, data)
466
+ except Exception:
467
+ _logger.debug(
468
+ "Failed to mirror attachment into FileBox", exc_info=True
469
+ )
470
+ except OSError as exc:
471
+ raise HTTPException(
472
+ status_code=500, detail=f"Failed to write attachment: {exc}"
473
+ ) from exc
474
+
475
+ seq = next_reply_seq(reply_paths.reply_history_dir)
476
+ dispatch, errors = dispatch_reply(reply_paths, next_seq=seq)
477
+ if errors:
478
+ raise HTTPException(status_code=400, detail=errors)
479
+ if dispatch is None:
480
+ raise HTTPException(status_code=500, detail="Failed to archive reply")
481
+ return {
482
+ "status": "ok",
483
+ "seq": dispatch.seq,
484
+ "reply": {"title": dispatch.reply.title, "body": dispatch.reply.body},
485
+ }
486
+
487
+ return router
488
+
489
+
490
+ __all__ = ["build_messages_routes"]