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,284 @@
1
+ """Analytics summary routes.
2
+
3
+ This module aggregates run/ticket/message metadata for the analytics dashboard
4
+ without relying on legacy autorunner endpoints. It intentionally reads from the
5
+ filesystem-backed ticket_flow store and ticket files to keep the UI consistent
6
+ with the rest of the app.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Optional
14
+
15
+ from fastapi import APIRouter
16
+
17
+ from ....core.flows.models import FlowEventType, FlowRunRecord, FlowRunStatus
18
+ from ....core.flows.store import FlowStore
19
+ from ....core.utils import find_repo_root
20
+ from ....tickets.files import list_ticket_paths, read_ticket, ticket_is_done
21
+ from ....tickets.outbox import parse_dispatch, resolve_outbox_paths
22
+ from ....tickets.replies import resolve_reply_paths
23
+
24
+
25
+ def _flows_db_path(repo_root: Path) -> Path:
26
+ return repo_root / ".codex-autorunner" / "flows.db"
27
+
28
+
29
+ def _select_primary_run(records: list[FlowRunRecord]) -> Optional[FlowRunRecord]:
30
+ """Select the primary run for analytics display.
31
+
32
+ Only considers the newest run (records[0]). If it's active or paused, return it.
33
+ If the newest run is terminal (completed/stopped/failed), return None to show idle.
34
+ This matches the backend's _active_or_paused_run() logic and prevents showing
35
+ stale data from old paused runs when newer runs have completed.
36
+ """
37
+ if not records:
38
+ return None
39
+ newest = records[0]
40
+ if (
41
+ FlowRunStatus(newest.status).is_active()
42
+ or FlowRunStatus(newest.status).is_paused()
43
+ ):
44
+ return newest
45
+ return None
46
+
47
+
48
+ def _parse_timestamp(value: Optional[str]) -> Optional[datetime]:
49
+ if not value:
50
+ return None
51
+ try:
52
+ if value.endswith("Z"):
53
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
54
+ return datetime.fromisoformat(value)
55
+ except ValueError:
56
+ return None
57
+
58
+
59
+ def _duration_seconds(
60
+ started_at: Optional[str], finished_at: Optional[str], status: str
61
+ ) -> Optional[float]:
62
+ start_dt = _parse_timestamp(started_at)
63
+ if not start_dt:
64
+ return None
65
+ end_dt = _parse_timestamp(finished_at)
66
+ if not end_dt and status in {
67
+ FlowRunStatus.RUNNING.value,
68
+ FlowRunStatus.PAUSED.value,
69
+ FlowRunStatus.PENDING.value,
70
+ }:
71
+ end_dt = datetime.now(timezone.utc)
72
+ if not end_dt:
73
+ return None
74
+ return (end_dt - start_dt).total_seconds()
75
+
76
+
77
+ def _ticket_counts(ticket_dir: Path) -> dict[str, int]:
78
+ total = 0
79
+ done = 0
80
+ for path in list_ticket_paths(ticket_dir):
81
+ total += 1
82
+ try:
83
+ if ticket_is_done(path):
84
+ done += 1
85
+ except Exception:
86
+ # Treat unreadable/invalid tickets as not-done but still count them.
87
+ continue
88
+ todo = max(total - done, 0)
89
+ return {"todo": todo, "done": done, "total": total}
90
+
91
+
92
+ def _count_history_dirs(history_dir: Path) -> int:
93
+ if not history_dir.exists() or not history_dir.is_dir():
94
+ return 0
95
+ count = 0
96
+ try:
97
+ for child in history_dir.iterdir():
98
+ try:
99
+ if child.is_dir() and len(child.name) == 4 and child.name.isdigit():
100
+ count += 1
101
+ except OSError:
102
+ continue
103
+ except OSError:
104
+ return count
105
+ return count
106
+
107
+
108
+ def _aggregate_diff_stats(dispatch_history_dir: Path) -> Dict[str, int]:
109
+ """Aggregate diff stats from all turn summaries in dispatch history.
110
+
111
+ Returns dict with insertions, deletions, files_changed totals.
112
+ """
113
+ totals = {"insertions": 0, "deletions": 0, "files_changed": 0}
114
+ if not dispatch_history_dir.exists() or not dispatch_history_dir.is_dir():
115
+ return totals
116
+
117
+ try:
118
+ for entry_dir in dispatch_history_dir.iterdir():
119
+ if not entry_dir.is_dir():
120
+ continue
121
+ if not (len(entry_dir.name) == 4 and entry_dir.name.isdigit()):
122
+ continue
123
+ dispatch_path = entry_dir / "DISPATCH.md"
124
+ if not dispatch_path.exists():
125
+ continue
126
+ try:
127
+ dispatch, _errors = parse_dispatch(dispatch_path)
128
+ if dispatch and dispatch.extra:
129
+ diff_stats = dispatch.extra.get("diff_stats")
130
+ if isinstance(diff_stats, dict):
131
+ totals["insertions"] += int(diff_stats.get("insertions") or 0)
132
+ totals["deletions"] += int(diff_stats.get("deletions") or 0)
133
+ totals["files_changed"] += int(
134
+ diff_stats.get("files_changed") or 0
135
+ )
136
+ except Exception:
137
+ continue
138
+ except OSError:
139
+ pass
140
+
141
+ return totals
142
+
143
+
144
+ def _build_summary(repo_root: Path) -> Dict[str, Any]:
145
+ from ....core.config import load_repo_config
146
+
147
+ ticket_dir = repo_root / ".codex-autorunner" / "tickets"
148
+ db_path = _flows_db_path(repo_root)
149
+ records: list[FlowRunRecord] = []
150
+ if db_path.exists():
151
+ try:
152
+ with FlowStore(
153
+ db_path, durable=load_repo_config(repo_root).durable_writes
154
+ ) as store:
155
+ records = store.list_flow_runs(flow_type="ticket_flow")
156
+ except Exception:
157
+ records = []
158
+
159
+ run_record = _select_primary_run(records)
160
+
161
+ default_run = {
162
+ "id": None,
163
+ "short_id": None,
164
+ "status": "idle",
165
+ "started_at": None,
166
+ "finished_at": None,
167
+ "duration_seconds": None,
168
+ "current_step": None,
169
+ "created_at": None,
170
+ }
171
+
172
+ run_data: Dict[str, Any] = default_run
173
+ turns: Dict[str, Optional[int]] = {
174
+ "total": None,
175
+ "current_ticket": None,
176
+ "dispatches": 0,
177
+ "replies": 0,
178
+ }
179
+ current_ticket: Optional[str] = None
180
+ agent_id: Optional[str] = None
181
+
182
+ if run_record:
183
+ run_data = {
184
+ "id": run_record.id,
185
+ "short_id": run_record.id.split("-")[0] if run_record.id else None,
186
+ "status": run_record.status.value,
187
+ "started_at": run_record.started_at,
188
+ "finished_at": run_record.finished_at,
189
+ "duration_seconds": _duration_seconds(
190
+ run_record.started_at, run_record.finished_at, run_record.status.value
191
+ ),
192
+ "current_step": run_record.current_step,
193
+ "created_at": run_record.created_at,
194
+ }
195
+
196
+ state = run_record.state if isinstance(run_record.state, dict) else {}
197
+ ticket_state = state.get("ticket_engine") if isinstance(state, dict) else {}
198
+ if isinstance(ticket_state, dict):
199
+ turns["total"] = ticket_state.get("total_turns") # type: ignore[index]
200
+ turns["current_ticket"] = ticket_state.get("ticket_turns") # type: ignore[index]
201
+ current_ticket = ticket_state.get("current_ticket") # type: ignore[assignment]
202
+ agent_id = ticket_state.get("last_agent_id") # type: ignore[assignment]
203
+
204
+ workspace_value = run_record.input_data.get("workspace_root")
205
+ workspace_root = Path(workspace_value) if workspace_value else repo_root
206
+ runs_dir = Path(
207
+ run_record.input_data.get("runs_dir") or ".codex-autorunner/runs"
208
+ )
209
+ outbox_paths = resolve_outbox_paths(
210
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_record.id
211
+ )
212
+ reply_paths = resolve_reply_paths(
213
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_record.id
214
+ )
215
+ turns["dispatches"] = _count_history_dirs(outbox_paths.dispatch_history_dir)
216
+ turns["replies"] = _count_history_dirs(reply_paths.reply_history_dir)
217
+ # Diff stats are now stored in FlowStore as DIFF_UPDATED events.
218
+ # Fallback to legacy dispatch history parsing if FlowStore query fails.
219
+ try:
220
+ with FlowStore(
221
+ db_path, durable=load_repo_config(repo_root).durable_writes
222
+ ) as store:
223
+ events = store.get_events_by_type(
224
+ run_record.id, FlowEventType.DIFF_UPDATED
225
+ )
226
+ totals = {"insertions": 0, "deletions": 0, "files_changed": 0}
227
+ for ev in events:
228
+ data = ev.data or {}
229
+ totals["insertions"] += int(data.get("insertions") or 0)
230
+ totals["deletions"] += int(data.get("deletions") or 0)
231
+ totals["files_changed"] += int(data.get("files_changed") or 0)
232
+ turns["diff_stats"] = totals
233
+ except Exception:
234
+ turns["diff_stats"] = _aggregate_diff_stats(
235
+ outbox_paths.dispatch_history_dir
236
+ )
237
+
238
+ # If current ticket is known, read its frontmatter to pick agent id when available.
239
+ if current_ticket:
240
+ current_path = (workspace_root / current_ticket).resolve()
241
+ try:
242
+ doc, _errors = read_ticket(current_path)
243
+ if doc and doc.frontmatter and getattr(doc.frontmatter, "agent", None):
244
+ agent_id = doc.frontmatter.agent
245
+ except Exception:
246
+ pass
247
+
248
+ ticket_counts = _ticket_counts(ticket_dir)
249
+
250
+ return {
251
+ "run": run_data,
252
+ "tickets": {
253
+ "todo_count": ticket_counts["todo"],
254
+ "done_count": ticket_counts["done"],
255
+ "total_count": ticket_counts["total"],
256
+ "current_ticket": current_ticket,
257
+ },
258
+ "turns": {
259
+ "total": turns.get("total"),
260
+ "current_ticket": turns.get("current_ticket"),
261
+ "dispatches": turns.get("dispatches"),
262
+ "replies": turns.get("replies"),
263
+ "diff_stats": turns.get("diff_stats"),
264
+ },
265
+ "agent": {
266
+ "id": agent_id,
267
+ "model": None,
268
+ },
269
+ }
270
+
271
+
272
+ def build_analytics_routes() -> APIRouter:
273
+ router = APIRouter(prefix="/api/analytics", tags=["analytics"])
274
+
275
+ @router.get("/summary")
276
+ def get_analytics_summary():
277
+ repo_root = find_repo_root()
278
+ data = _build_summary(repo_root)
279
+ return data
280
+
281
+ return router
282
+
283
+
284
+ __all__ = ["build_analytics_routes"]
@@ -0,0 +1,132 @@
1
+ """
2
+ App-server support routes (thread registry).
3
+ """
4
+
5
+ from pathlib import Path
6
+
7
+ from fastapi import APIRouter, HTTPException, Request
8
+ from fastapi.responses import FileResponse, StreamingResponse
9
+
10
+ from ....core.app_server_threads import normalize_feature_key
11
+ from ....core.utils import is_within
12
+ from ....integrations.app_server.client import CodexAppServerError
13
+ from ..schemas import (
14
+ AppServerThreadArchiveRequest,
15
+ AppServerThreadArchiveResponse,
16
+ AppServerThreadResetAllResponse,
17
+ AppServerThreadResetRequest,
18
+ AppServerThreadResetResponse,
19
+ AppServerThreadsResponse,
20
+ )
21
+ from .shared import SSE_HEADERS
22
+
23
+
24
+ def build_app_server_routes() -> APIRouter:
25
+ router = APIRouter()
26
+
27
+ @router.get("/api/app-server/turns/{turn_id}/events")
28
+ async def stream_app_server_turn_events(
29
+ turn_id: str, request: Request, thread_id: str
30
+ ):
31
+ events = getattr(request.app.state, "app_server_events", None)
32
+ if events is None:
33
+ raise HTTPException(status_code=404, detail="App-server events unavailable")
34
+ if not thread_id:
35
+ raise HTTPException(status_code=400, detail="thread_id is required")
36
+ return StreamingResponse(
37
+ events.stream(thread_id, turn_id),
38
+ media_type="text/event-stream",
39
+ headers=SSE_HEADERS,
40
+ )
41
+
42
+ @router.get("/api/app-server/threads", response_model=AppServerThreadsResponse)
43
+ def app_server_threads(request: Request):
44
+ registry = request.app.state.app_server_threads
45
+ return registry.feature_map()
46
+
47
+ @router.get("/api/app-server/models")
48
+ async def app_server_models(request: Request):
49
+ engine = request.app.state.engine
50
+ supervisor = request.app.state.app_server_supervisor
51
+ try:
52
+ client = await supervisor.get_client(engine.repo_root)
53
+ return await client.model_list()
54
+ except CodexAppServerError as exc:
55
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
56
+
57
+ @router.post(
58
+ "/api/app-server/threads/reset", response_model=AppServerThreadResetResponse
59
+ )
60
+ def reset_app_server_thread(request: Request, payload: AppServerThreadResetRequest):
61
+ registry = request.app.state.app_server_threads
62
+ try:
63
+ key = normalize_feature_key(payload.key)
64
+ except ValueError as exc:
65
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
66
+ cleared = registry.reset_thread(key)
67
+ return {"status": "ok", "key": key, "cleared": cleared}
68
+
69
+ @router.post(
70
+ "/api/app-server/threads/archive",
71
+ response_model=AppServerThreadArchiveResponse,
72
+ )
73
+ async def archive_app_server_thread(
74
+ request: Request, payload: AppServerThreadArchiveRequest
75
+ ):
76
+ thread_id = payload.thread_id.strip()
77
+ if not thread_id:
78
+ raise HTTPException(status_code=400, detail="thread_id is required")
79
+ engine = request.app.state.engine
80
+ supervisor = request.app.state.app_server_supervisor
81
+ try:
82
+ client = await supervisor.get_client(engine.repo_root)
83
+ await client.thread_archive(thread_id)
84
+ except CodexAppServerError as exc:
85
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
86
+ return {"status": "ok", "thread_id": thread_id, "archived": True}
87
+
88
+ @router.post(
89
+ "/api/app-server/threads/reset-all",
90
+ response_model=AppServerThreadResetAllResponse,
91
+ )
92
+ def reset_app_server_threads(request: Request):
93
+ registry = request.app.state.app_server_threads
94
+ registry.reset_all()
95
+ return {"status": "ok", "cleared": True}
96
+
97
+ @router.get("/api/app-server/threads/backup")
98
+ def download_app_server_threads_backup(request: Request):
99
+ registry = request.app.state.app_server_threads
100
+ notice = registry.corruption_notice() or {}
101
+ backup_path = notice.get("backup_path")
102
+ if not isinstance(backup_path, str) or not backup_path:
103
+ raise HTTPException(status_code=404, detail="No backup available")
104
+ path = Path(backup_path)
105
+ engine = request.app.state.engine
106
+ if not is_within(engine.repo_root, path):
107
+ raise HTTPException(status_code=400, detail="Invalid backup path")
108
+ if not path.exists():
109
+ raise HTTPException(status_code=404, detail="Backup not found")
110
+ return FileResponse(path, filename=path.name)
111
+
112
+ @router.get("/api/app-server/account")
113
+ async def app_server_account(request: Request):
114
+ engine = request.app.state.engine
115
+ supervisor = request.app.state.app_server_supervisor
116
+ try:
117
+ client = await supervisor.get_client(engine.repo_root)
118
+ return await client.account_read()
119
+ except CodexAppServerError as exc:
120
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
121
+
122
+ @router.get("/api/app-server/rate-limits")
123
+ async def app_server_rate_limits(request: Request):
124
+ engine = request.app.state.engine
125
+ supervisor = request.app.state.app_server_supervisor
126
+ try:
127
+ client = await supervisor.get_client(engine.repo_root)
128
+ return await client.rate_limits_read()
129
+ except CodexAppServerError as exc:
130
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
131
+
132
+ return router