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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/__init__.py +20 -0
  3. codex_autorunner/agents/base.py +2 -2
  4. codex_autorunner/agents/codex/harness.py +1 -1
  5. codex_autorunner/agents/opencode/__init__.py +4 -0
  6. codex_autorunner/agents/opencode/agent_config.py +104 -0
  7. codex_autorunner/agents/opencode/client.py +305 -28
  8. codex_autorunner/agents/opencode/harness.py +71 -20
  9. codex_autorunner/agents/opencode/logging.py +225 -0
  10. codex_autorunner/agents/opencode/run_prompt.py +261 -0
  11. codex_autorunner/agents/opencode/runtime.py +1202 -132
  12. codex_autorunner/agents/opencode/supervisor.py +194 -68
  13. codex_autorunner/agents/registry.py +258 -0
  14. codex_autorunner/agents/types.py +2 -2
  15. codex_autorunner/api.py +25 -0
  16. codex_autorunner/bootstrap.py +19 -40
  17. codex_autorunner/cli.py +234 -151
  18. codex_autorunner/core/about_car.py +44 -32
  19. codex_autorunner/core/adapter_utils.py +21 -0
  20. codex_autorunner/core/app_server_events.py +15 -6
  21. codex_autorunner/core/app_server_logging.py +55 -15
  22. codex_autorunner/core/app_server_prompts.py +28 -259
  23. codex_autorunner/core/app_server_threads.py +15 -26
  24. codex_autorunner/core/circuit_breaker.py +183 -0
  25. codex_autorunner/core/codex_runner.py +6 -0
  26. codex_autorunner/core/config.py +555 -133
  27. codex_autorunner/core/docs.py +54 -9
  28. codex_autorunner/core/drafts.py +82 -0
  29. codex_autorunner/core/engine.py +828 -274
  30. codex_autorunner/core/exceptions.py +60 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +178 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +75 -0
  35. codex_autorunner/core/flows/runtime.py +351 -0
  36. codex_autorunner/core/flows/store.py +485 -0
  37. codex_autorunner/core/flows/transition.py +133 -0
  38. codex_autorunner/core/flows/worker_process.py +242 -0
  39. codex_autorunner/core/hub.py +21 -13
  40. codex_autorunner/core/locks.py +118 -1
  41. codex_autorunner/core/logging_utils.py +9 -6
  42. codex_autorunner/core/path_utils.py +123 -0
  43. codex_autorunner/core/prompt.py +15 -7
  44. codex_autorunner/core/redaction.py +29 -0
  45. codex_autorunner/core/retry.py +61 -0
  46. codex_autorunner/core/review.py +888 -0
  47. codex_autorunner/core/review_context.py +161 -0
  48. codex_autorunner/core/run_index.py +223 -0
  49. codex_autorunner/core/runner_controller.py +44 -1
  50. codex_autorunner/core/runner_process.py +30 -1
  51. codex_autorunner/core/sqlite_utils.py +32 -0
  52. codex_autorunner/core/state.py +273 -44
  53. codex_autorunner/core/static_assets.py +55 -0
  54. codex_autorunner/core/supervisor_utils.py +67 -0
  55. codex_autorunner/core/text_delta_coalescer.py +43 -0
  56. codex_autorunner/core/update.py +20 -11
  57. codex_autorunner/core/update_runner.py +2 -0
  58. codex_autorunner/core/usage.py +107 -75
  59. codex_autorunner/core/utils.py +167 -3
  60. codex_autorunner/discovery.py +3 -3
  61. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  62. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  63. codex_autorunner/integrations/agents/__init__.py +27 -0
  64. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  65. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  66. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  67. codex_autorunner/integrations/agents/run_event.py +71 -0
  68. codex_autorunner/integrations/app_server/client.py +708 -153
  69. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  70. codex_autorunner/integrations/telegram/adapter.py +474 -185
  71. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  72. codex_autorunner/integrations/telegram/config.py +239 -1
  73. codex_autorunner/integrations/telegram/constants.py +19 -1
  74. codex_autorunner/integrations/telegram/dispatch.py +44 -8
  75. codex_autorunner/integrations/telegram/doctor.py +47 -0
  76. codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
  77. codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
  78. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
  79. codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
  80. codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
  81. codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
  82. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  83. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
  84. codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
  85. codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
  86. codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
  87. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
  88. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
  89. codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
  90. codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
  91. codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
  92. codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
  93. codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
  94. codex_autorunner/integrations/telegram/helpers.py +90 -18
  95. codex_autorunner/integrations/telegram/notifications.py +126 -35
  96. codex_autorunner/integrations/telegram/outbox.py +214 -43
  97. codex_autorunner/integrations/telegram/progress_stream.py +42 -19
  98. codex_autorunner/integrations/telegram/runtime.py +24 -13
  99. codex_autorunner/integrations/telegram/service.py +500 -129
  100. codex_autorunner/integrations/telegram/state.py +1278 -330
  101. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  102. codex_autorunner/integrations/telegram/transport.py +37 -4
  103. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  104. codex_autorunner/integrations/telegram/types.py +22 -2
  105. codex_autorunner/integrations/telegram/voice.py +14 -15
  106. codex_autorunner/manifest.py +2 -0
  107. codex_autorunner/plugin_api.py +22 -0
  108. codex_autorunner/routes/__init__.py +25 -14
  109. codex_autorunner/routes/agents.py +18 -78
  110. codex_autorunner/routes/analytics.py +239 -0
  111. codex_autorunner/routes/base.py +142 -113
  112. codex_autorunner/routes/file_chat.py +836 -0
  113. codex_autorunner/routes/flows.py +980 -0
  114. codex_autorunner/routes/messages.py +459 -0
  115. codex_autorunner/routes/repos.py +17 -0
  116. codex_autorunner/routes/review.py +148 -0
  117. codex_autorunner/routes/sessions.py +16 -8
  118. codex_autorunner/routes/settings.py +22 -0
  119. codex_autorunner/routes/shared.py +33 -3
  120. codex_autorunner/routes/system.py +22 -1
  121. codex_autorunner/routes/usage.py +87 -0
  122. codex_autorunner/routes/voice.py +5 -13
  123. codex_autorunner/routes/workspace.py +271 -0
  124. codex_autorunner/server.py +2 -1
  125. codex_autorunner/static/agentControls.js +9 -1
  126. codex_autorunner/static/agentEvents.js +248 -0
  127. codex_autorunner/static/app.js +27 -22
  128. codex_autorunner/static/autoRefresh.js +29 -1
  129. codex_autorunner/static/bootstrap.js +1 -0
  130. codex_autorunner/static/bus.js +1 -0
  131. codex_autorunner/static/cache.js +1 -0
  132. codex_autorunner/static/constants.js +20 -4
  133. codex_autorunner/static/dashboard.js +162 -150
  134. codex_autorunner/static/diffRenderer.js +37 -0
  135. codex_autorunner/static/docChatCore.js +324 -0
  136. codex_autorunner/static/docChatStorage.js +65 -0
  137. codex_autorunner/static/docChatVoice.js +65 -0
  138. codex_autorunner/static/docEditor.js +133 -0
  139. codex_autorunner/static/env.js +1 -0
  140. codex_autorunner/static/eventSummarizer.js +166 -0
  141. codex_autorunner/static/fileChat.js +182 -0
  142. codex_autorunner/static/health.js +155 -0
  143. codex_autorunner/static/hub.js +67 -126
  144. codex_autorunner/static/index.html +788 -807
  145. codex_autorunner/static/liveUpdates.js +59 -0
  146. codex_autorunner/static/loader.js +1 -0
  147. codex_autorunner/static/messages.js +470 -0
  148. codex_autorunner/static/mobileCompact.js +2 -1
  149. codex_autorunner/static/settings.js +24 -205
  150. codex_autorunner/static/styles.css +7577 -3758
  151. codex_autorunner/static/tabs.js +28 -5
  152. codex_autorunner/static/terminal.js +14 -0
  153. codex_autorunner/static/terminalManager.js +53 -59
  154. codex_autorunner/static/ticketChatActions.js +333 -0
  155. codex_autorunner/static/ticketChatEvents.js +16 -0
  156. codex_autorunner/static/ticketChatStorage.js +16 -0
  157. codex_autorunner/static/ticketChatStream.js +264 -0
  158. codex_autorunner/static/ticketEditor.js +750 -0
  159. codex_autorunner/static/ticketVoice.js +9 -0
  160. codex_autorunner/static/tickets.js +1315 -0
  161. codex_autorunner/static/utils.js +32 -3
  162. codex_autorunner/static/voice.js +21 -7
  163. codex_autorunner/static/workspace.js +672 -0
  164. codex_autorunner/static/workspaceApi.js +53 -0
  165. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  166. codex_autorunner/tickets/__init__.py +20 -0
  167. codex_autorunner/tickets/agent_pool.py +377 -0
  168. codex_autorunner/tickets/files.py +85 -0
  169. codex_autorunner/tickets/frontmatter.py +55 -0
  170. codex_autorunner/tickets/lint.py +102 -0
  171. codex_autorunner/tickets/models.py +95 -0
  172. codex_autorunner/tickets/outbox.py +232 -0
  173. codex_autorunner/tickets/replies.py +179 -0
  174. codex_autorunner/tickets/runner.py +823 -0
  175. codex_autorunner/tickets/spec_ingest.py +77 -0
  176. codex_autorunner/voice/capture.py +7 -7
  177. codex_autorunner/voice/service.py +51 -9
  178. codex_autorunner/web/app.py +419 -199
  179. codex_autorunner/web/hub_jobs.py +13 -2
  180. codex_autorunner/web/middleware.py +47 -13
  181. codex_autorunner/web/pty_session.py +26 -13
  182. codex_autorunner/web/schemas.py +114 -109
  183. codex_autorunner/web/static_assets.py +55 -42
  184. codex_autorunner/web/static_refresh.py +86 -0
  185. codex_autorunner/workspace/__init__.py +40 -0
  186. codex_autorunner/workspace/paths.py +319 -0
  187. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
  188. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  189. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  190. codex_autorunner/core/doc_chat.py +0 -1415
  191. codex_autorunner/core/snapshot.py +0 -580
  192. codex_autorunner/integrations/github/chatops.py +0 -268
  193. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  194. codex_autorunner/routes/docs.py +0 -381
  195. codex_autorunner/routes/github.py +0 -327
  196. codex_autorunner/routes/runs.py +0 -118
  197. codex_autorunner/spec_ingest.py +0 -788
  198. codex_autorunner/static/docChatActions.js +0 -279
  199. codex_autorunner/static/docChatEvents.js +0 -300
  200. codex_autorunner/static/docChatRender.js +0 -205
  201. codex_autorunner/static/docChatStream.js +0 -361
  202. codex_autorunner/static/docs.js +0 -20
  203. codex_autorunner/static/docsClipboard.js +0 -69
  204. codex_autorunner/static/docsCrud.js +0 -257
  205. codex_autorunner/static/docsDocUpdates.js +0 -62
  206. codex_autorunner/static/docsDrafts.js +0 -16
  207. codex_autorunner/static/docsElements.js +0 -69
  208. codex_autorunner/static/docsInit.js +0 -274
  209. codex_autorunner/static/docsParse.js +0 -160
  210. codex_autorunner/static/docsSnapshot.js +0 -87
  211. codex_autorunner/static/docsSpecIngest.js +0 -263
  212. codex_autorunner/static/docsState.js +0 -127
  213. codex_autorunner/static/docsThreadRegistry.js +0 -44
  214. codex_autorunner/static/docsUi.js +0 -153
  215. codex_autorunner/static/docsVoice.js +0 -56
  216. codex_autorunner/static/github.js +0 -442
  217. codex_autorunner/static/logs.js +0 -640
  218. codex_autorunner/static/runs.js +0 -409
  219. codex_autorunner/static/snapshot.js +0 -124
  220. codex_autorunner/static/state.js +0 -86
  221. codex_autorunner/static/todoPreview.js +0 -27
  222. codex_autorunner/workspace.py +0 -16
  223. codex_autorunner-0.1.1.dist-info/RECORD +0 -191
  224. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  225. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  226. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -1,16 +1,17 @@
1
1
  """
2
2
  Modular API routes for the codex-autorunner server.
3
3
 
4
- This package splits the monolithic api_routes.py into focused modules:
5
- - base: Index, state streaming, and general endpoints
4
+ This package splits monolithic api_routes.py into focused modules:
5
+ - base: Index, WebSocket terminal, and general endpoints
6
6
  - agents: Agent harness models and event streaming
7
7
  - app_server: App-server thread registry endpoints
8
- - docs: Document management (read/write) and chat
9
- - github: GitHub integration endpoints
8
+ - workspace: Optional workspace docs (active_context/decisions/spec)
9
+ - flows: Flow runtime management (start/stop/resume/status/events/artifacts)
10
+ - messages: Inbox/message wrappers over ticket_flow dispatch + reply histories
10
11
  - repos: Run control (start/stop/resume/reset)
11
- - runs: Run telemetry and artifacts
12
12
  - sessions: Terminal session registry endpoints
13
13
  - settings: Session settings for autorunner overrides
14
+ - file_chat: Unified file chat (tickets + workspace docs)
14
15
  - voice: Voice transcription and config
15
16
  - terminal_images: Terminal image uploads
16
17
  """
@@ -20,25 +21,29 @@ from pathlib import Path
20
21
  from fastapi import APIRouter
21
22
 
22
23
  from .agents import build_agents_routes
24
+ from .analytics import build_analytics_routes
23
25
  from .app_server import build_app_server_routes
24
- from .base import build_base_routes
25
- from .docs import build_docs_routes
26
- from .github import build_github_routes
26
+ from .base import build_base_routes, build_frontend_routes
27
+ from .file_chat import build_file_chat_routes
28
+ from .flows import build_flow_routes
29
+ from .messages import build_messages_routes
27
30
  from .repos import build_repos_routes
28
- from .runs import build_runs_routes
31
+ from .review import build_review_routes
29
32
  from .sessions import build_sessions_routes
30
33
  from .settings import build_settings_routes
31
34
  from .system import build_system_routes
32
35
  from .terminal_images import build_terminal_image_routes
36
+ from .usage import build_usage_routes
33
37
  from .voice import build_voice_routes
38
+ from .workspace import build_workspace_routes
34
39
 
35
40
 
36
41
  def build_repo_router(static_dir: Path) -> APIRouter:
37
42
  """
38
- Build the complete API router by combining all route modules.
43
+ Build complete API router by combining all route modules.
39
44
 
40
45
  Args:
41
- static_dir: Path to the static assets directory
46
+ static_dir: Path to static assets directory
42
47
 
43
48
  Returns:
44
49
  Combined APIRouter with all endpoints
@@ -47,17 +52,23 @@ def build_repo_router(static_dir: Path) -> APIRouter:
47
52
 
48
53
  # Include all route modules
49
54
  router.include_router(build_base_routes(static_dir))
55
+ router.include_router(build_analytics_routes())
50
56
  router.include_router(build_agents_routes())
51
57
  router.include_router(build_app_server_routes())
52
- router.include_router(build_docs_routes())
53
- router.include_router(build_github_routes())
58
+ router.include_router(build_workspace_routes())
59
+ router.include_router(build_flow_routes())
60
+ router.include_router(build_file_chat_routes())
61
+ router.include_router(build_messages_routes())
54
62
  router.include_router(build_repos_routes())
55
- router.include_router(build_runs_routes())
63
+ router.include_router(build_review_routes())
56
64
  router.include_router(build_sessions_routes())
57
65
  router.include_router(build_settings_routes())
58
66
  router.include_router(build_system_routes())
59
67
  router.include_router(build_terminal_image_routes())
68
+ router.include_router(build_usage_routes())
60
69
  router.include_router(build_voice_routes())
70
+ # Include frontend routes last to avoid shadowing API routes
71
+ router.include_router(build_frontend_routes(static_dir))
61
72
 
62
73
  return router
63
74
 
@@ -21,16 +21,27 @@ def _available_agents(request: Request) -> tuple[list[dict[str, str]], str]:
21
21
  default_agent: Optional[str] = None
22
22
 
23
23
  if getattr(request.app.state, "app_server_supervisor", None) is not None:
24
- agents.append({"id": "codex", "name": "Codex"})
24
+ agents.append({"id": "codex", "name": "Codex", "protocol_version": "2.0"})
25
25
  default_agent = "codex"
26
26
 
27
27
  if getattr(request.app.state, "opencode_supervisor", None) is not None:
28
- agents.append({"id": "opencode", "name": "OpenCode"})
28
+ supervisor = getattr(request.app.state, "opencode_supervisor", None)
29
+ version = None
30
+ if supervisor and hasattr(supervisor, "_handles"):
31
+ handles = supervisor._handles
32
+ if handles:
33
+ first_handle = next(iter(handles.values()), None)
34
+ if first_handle:
35
+ version = getattr(first_handle, "version", None)
36
+ agent_data = {"id": "opencode", "name": "OpenCode"}
37
+ if version:
38
+ agent_data["version"] = str(version)
39
+ agents.append(agent_data)
29
40
  if default_agent is None:
30
41
  default_agent = "opencode"
31
42
 
32
43
  if not agents:
33
- agents = [{"id": "codex", "name": "Codex"}]
44
+ agents = [{"id": "codex", "name": "Codex", "protocol_version": "2.0"}]
34
45
  default_agent = "codex"
35
46
 
36
47
  return agents, default_agent or "codex"
@@ -51,76 +62,6 @@ def _serialize_model_catalog(catalog: ModelCatalog) -> dict[str, Any]:
51
62
  }
52
63
 
53
64
 
54
- def _coerce_opencode_providers(payload: Any) -> list[dict[str, Any]]:
55
- if isinstance(payload, dict):
56
- providers = payload.get("providers")
57
- if isinstance(providers, list):
58
- return [entry for entry in providers if isinstance(entry, dict)]
59
- if isinstance(payload, list):
60
- return [entry for entry in payload if isinstance(entry, dict)]
61
- return []
62
-
63
-
64
- def _build_opencode_model_catalog(payload: Any) -> ModelCatalog:
65
- from ..agents.types import ModelSpec
66
-
67
- providers = _coerce_opencode_providers(payload)
68
- models: list[ModelSpec] = []
69
- default_model = ""
70
- if isinstance(payload, dict):
71
- raw_default = payload.get("default")
72
- if isinstance(raw_default, dict):
73
- for provider in providers:
74
- provider_id = provider.get("id") or provider.get("providerID")
75
- if (
76
- isinstance(provider_id, str)
77
- and provider_id
78
- and provider_id in raw_default
79
- ):
80
- default_model_id = raw_default[provider_id]
81
- if isinstance(default_model_id, str) and default_model_id:
82
- default_model = f"{provider_id}/{default_model_id}"
83
- break
84
- for provider in providers:
85
- provider_id = provider.get("id") or provider.get("providerID")
86
- if not isinstance(provider_id, str) or not provider_id:
87
- continue
88
- models_map = provider.get("models")
89
- if not isinstance(models_map, dict):
90
- continue
91
- for model_id, model in models_map.items():
92
- if not isinstance(model_id, str) or not isinstance(model, dict):
93
- continue
94
- model_name = model.get("name") or model.get("id") or model_id
95
- display_name = (
96
- model_name if isinstance(model_name, str) and model_name else model_id
97
- )
98
- capabilities = model.get("capabilities")
99
- supports_reasoning = False
100
- if isinstance(capabilities, dict):
101
- supports_reasoning = bool(capabilities.get("reasoning"))
102
- variants = model.get("variants")
103
- reasoning_options: list[str] = []
104
- if isinstance(variants, dict):
105
- reasoning_options = [
106
- key for key in variants.keys() if isinstance(key, str)
107
- ]
108
- if reasoning_options:
109
- supports_reasoning = True
110
- full_id = f"{provider_id}/{model_id}"
111
- models.append(
112
- ModelSpec(
113
- id=full_id,
114
- display_name=display_name,
115
- supports_reasoning=supports_reasoning,
116
- reasoning_options=reasoning_options,
117
- )
118
- )
119
- if not default_model and models:
120
- default_model = models[0].id
121
- return ModelCatalog(default_model=default_model, models=models)
122
-
123
-
124
65
  def build_agents_routes() -> APIRouter:
125
66
  router = APIRouter()
126
67
 
@@ -138,8 +79,8 @@ def build_agents_routes() -> APIRouter:
138
79
  events = request.app.state.app_server_events
139
80
  if supervisor is None:
140
81
  raise HTTPException(status_code=404, detail="Codex harness unavailable")
141
- harness = CodexHarness(supervisor, events)
142
- catalog = await harness.model_catalog(engine.repo_root)
82
+ codex_harness = CodexHarness(supervisor, events)
83
+ catalog = await codex_harness.model_catalog(engine.repo_root)
143
84
  return _serialize_model_catalog(catalog)
144
85
  if agent_id == "opencode":
145
86
  supervisor = getattr(request.app.state, "opencode_supervisor", None)
@@ -148,9 +89,8 @@ def build_agents_routes() -> APIRouter:
148
89
  status_code=404, detail="OpenCode harness unavailable"
149
90
  )
150
91
  try:
151
- client = await supervisor.get_client(engine.repo_root)
152
- payload = await client.providers(directory=str(engine.repo_root))
153
- catalog = _build_opencode_model_catalog(payload)
92
+ opencode_harness = OpenCodeHarness(supervisor)
93
+ catalog = await opencode_harness.model_catalog(engine.repo_root)
154
94
  return _serialize_model_catalog(catalog)
155
95
  except OpenCodeSupervisorError as exc:
156
96
  raise HTTPException(status_code=502, detail=str(exc)) from exc
@@ -0,0 +1,239 @@
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 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 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 _load_flow_store(repo_root: Path) -> Optional[FlowStore]:
30
+ db_path = _flows_db_path(repo_root)
31
+ if not db_path.exists():
32
+ return None
33
+ store = FlowStore(db_path)
34
+ try:
35
+ store.initialize()
36
+ except Exception:
37
+ return None
38
+ return store
39
+
40
+
41
+ def _select_primary_run(records: list[FlowRunRecord]) -> Optional[FlowRunRecord]:
42
+ """Select the primary run for analytics display.
43
+
44
+ Only considers the newest run (records[0]). If it's active or paused, return it.
45
+ If the newest run is terminal (completed/stopped/failed), return None to show idle.
46
+ This matches the backend's _active_or_paused_run() logic and prevents showing
47
+ stale data from old paused runs when newer runs have completed.
48
+ """
49
+ if not records:
50
+ return None
51
+ newest = records[0]
52
+ if (
53
+ FlowRunStatus(newest.status).is_active()
54
+ or FlowRunStatus(newest.status).is_paused()
55
+ ):
56
+ return newest
57
+ return None
58
+
59
+
60
+ def _parse_timestamp(value: Optional[str]) -> Optional[datetime]:
61
+ if not value:
62
+ return None
63
+ try:
64
+ if value.endswith("Z"):
65
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
66
+ return datetime.fromisoformat(value)
67
+ except ValueError:
68
+ return None
69
+
70
+
71
+ def _duration_seconds(
72
+ started_at: Optional[str], finished_at: Optional[str], status: str
73
+ ) -> Optional[float]:
74
+ start_dt = _parse_timestamp(started_at)
75
+ if not start_dt:
76
+ return None
77
+ end_dt = _parse_timestamp(finished_at)
78
+ if not end_dt and status in {
79
+ FlowRunStatus.RUNNING.value,
80
+ FlowRunStatus.PAUSED.value,
81
+ FlowRunStatus.PENDING.value,
82
+ }:
83
+ end_dt = datetime.now(timezone.utc)
84
+ if not end_dt:
85
+ return None
86
+ return (end_dt - start_dt).total_seconds()
87
+
88
+
89
+ def _ticket_counts(ticket_dir: Path) -> dict[str, int]:
90
+ total = 0
91
+ done = 0
92
+ for path in list_ticket_paths(ticket_dir):
93
+ total += 1
94
+ try:
95
+ if ticket_is_done(path):
96
+ done += 1
97
+ except Exception:
98
+ # Treat unreadable/invalid tickets as not-done but still count them.
99
+ continue
100
+ todo = max(total - done, 0)
101
+ return {"todo": todo, "done": done, "total": total}
102
+
103
+
104
+ def _count_history_dirs(history_dir: Path) -> int:
105
+ if not history_dir.exists() or not history_dir.is_dir():
106
+ return 0
107
+ count = 0
108
+ try:
109
+ for child in history_dir.iterdir():
110
+ try:
111
+ if child.is_dir() and len(child.name) == 4 and child.name.isdigit():
112
+ count += 1
113
+ except OSError:
114
+ continue
115
+ except OSError:
116
+ return count
117
+ return count
118
+
119
+
120
+ def _build_summary(repo_root: Path) -> Dict[str, Any]:
121
+ ticket_dir = repo_root / ".codex-autorunner" / "tickets"
122
+ store = _load_flow_store(repo_root)
123
+ records: list[FlowRunRecord] = []
124
+ if store:
125
+ try:
126
+ records = store.list_flow_runs(flow_type="ticket_flow")
127
+ except Exception:
128
+ records = []
129
+ finally:
130
+ try:
131
+ store.close()
132
+ except Exception:
133
+ pass
134
+
135
+ run_record = _select_primary_run(records)
136
+
137
+ default_run = {
138
+ "id": None,
139
+ "short_id": None,
140
+ "status": "idle",
141
+ "started_at": None,
142
+ "finished_at": None,
143
+ "duration_seconds": None,
144
+ "current_step": None,
145
+ "created_at": None,
146
+ }
147
+
148
+ run_data: Dict[str, Any] = default_run
149
+ turns: Dict[str, Optional[int]] = {
150
+ "total": None,
151
+ "current_ticket": None,
152
+ "dispatches": 0,
153
+ "replies": 0,
154
+ }
155
+ current_ticket: Optional[str] = None
156
+ agent_id: Optional[str] = None
157
+
158
+ if run_record:
159
+ run_data = {
160
+ "id": run_record.id,
161
+ "short_id": run_record.id.split("-")[0] if run_record.id else None,
162
+ "status": run_record.status.value,
163
+ "started_at": run_record.started_at,
164
+ "finished_at": run_record.finished_at,
165
+ "duration_seconds": _duration_seconds(
166
+ run_record.started_at, run_record.finished_at, run_record.status.value
167
+ ),
168
+ "current_step": run_record.current_step,
169
+ "created_at": run_record.created_at,
170
+ }
171
+
172
+ state = run_record.state if isinstance(run_record.state, dict) else {}
173
+ ticket_state = state.get("ticket_engine") if isinstance(state, dict) else {}
174
+ if isinstance(ticket_state, dict):
175
+ turns["total"] = ticket_state.get("total_turns") # type: ignore[index]
176
+ turns["current_ticket"] = ticket_state.get("ticket_turns") # type: ignore[index]
177
+ current_ticket = ticket_state.get("current_ticket") # type: ignore[assignment]
178
+ agent_id = ticket_state.get("last_agent_id") # type: ignore[assignment]
179
+
180
+ workspace_value = run_record.input_data.get("workspace_root")
181
+ workspace_root = Path(workspace_value) if workspace_value else repo_root
182
+ runs_dir = Path(
183
+ run_record.input_data.get("runs_dir") or ".codex-autorunner/runs"
184
+ )
185
+ outbox_paths = resolve_outbox_paths(
186
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_record.id
187
+ )
188
+ reply_paths = resolve_reply_paths(
189
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_record.id
190
+ )
191
+ turns["dispatches"] = _count_history_dirs(outbox_paths.dispatch_history_dir)
192
+ turns["replies"] = _count_history_dirs(reply_paths.reply_history_dir)
193
+
194
+ # If current ticket is known, read its frontmatter to pick agent id when available.
195
+ if current_ticket:
196
+ current_path = (workspace_root / current_ticket).resolve()
197
+ try:
198
+ doc, _errors = read_ticket(current_path)
199
+ if doc and doc.frontmatter and getattr(doc.frontmatter, "agent", None):
200
+ agent_id = doc.frontmatter.agent
201
+ except Exception:
202
+ pass
203
+
204
+ ticket_counts = _ticket_counts(ticket_dir)
205
+
206
+ return {
207
+ "run": run_data,
208
+ "tickets": {
209
+ "todo_count": ticket_counts["todo"],
210
+ "done_count": ticket_counts["done"],
211
+ "total_count": ticket_counts["total"],
212
+ "current_ticket": current_ticket,
213
+ },
214
+ "turns": {
215
+ "total": turns.get("total"),
216
+ "current_ticket": turns.get("current_ticket"),
217
+ "dispatches": turns.get("dispatches"),
218
+ "replies": turns.get("replies"),
219
+ },
220
+ "agent": {
221
+ "id": agent_id,
222
+ "model": None,
223
+ },
224
+ }
225
+
226
+
227
+ def build_analytics_routes() -> APIRouter:
228
+ router = APIRouter(prefix="/api/analytics", tags=["analytics"])
229
+
230
+ @router.get("/summary")
231
+ def get_analytics_summary():
232
+ repo_root = find_repo_root()
233
+ data = _build_summary(repo_root)
234
+ return data
235
+
236
+ return router
237
+
238
+
239
+ __all__ = ["build_analytics_routes"]