codex-autorunner 1.1.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 (127) hide show
  1. codex_autorunner/agents/opencode/client.py +113 -4
  2. codex_autorunner/agents/opencode/supervisor.py +4 -0
  3. codex_autorunner/agents/registry.py +17 -7
  4. codex_autorunner/bootstrap.py +219 -1
  5. codex_autorunner/core/__init__.py +17 -1
  6. codex_autorunner/core/about_car.py +114 -1
  7. codex_autorunner/core/app_server_threads.py +6 -0
  8. codex_autorunner/core/config.py +236 -1
  9. codex_autorunner/core/context_awareness.py +38 -0
  10. codex_autorunner/core/docs.py +0 -122
  11. codex_autorunner/core/filebox.py +265 -0
  12. codex_autorunner/core/flows/controller.py +71 -1
  13. codex_autorunner/core/flows/reconciler.py +4 -1
  14. codex_autorunner/core/flows/runtime.py +22 -0
  15. codex_autorunner/core/flows/store.py +61 -9
  16. codex_autorunner/core/flows/transition.py +23 -16
  17. codex_autorunner/core/flows/ux_helpers.py +18 -3
  18. codex_autorunner/core/flows/worker_process.py +32 -6
  19. codex_autorunner/core/hub.py +198 -41
  20. codex_autorunner/core/lifecycle_events.py +253 -0
  21. codex_autorunner/core/path_utils.py +2 -1
  22. codex_autorunner/core/pma_audit.py +224 -0
  23. codex_autorunner/core/pma_context.py +496 -0
  24. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  25. codex_autorunner/core/pma_lifecycle.py +527 -0
  26. codex_autorunner/core/pma_queue.py +367 -0
  27. codex_autorunner/core/pma_safety.py +221 -0
  28. codex_autorunner/core/pma_state.py +115 -0
  29. codex_autorunner/core/ports/agent_backend.py +2 -5
  30. codex_autorunner/core/ports/run_event.py +1 -4
  31. codex_autorunner/core/prompt.py +0 -80
  32. codex_autorunner/core/prompts.py +56 -172
  33. codex_autorunner/core/redaction.py +0 -4
  34. codex_autorunner/core/review_context.py +11 -9
  35. codex_autorunner/core/runner_controller.py +35 -33
  36. codex_autorunner/core/runner_state.py +147 -0
  37. codex_autorunner/core/runtime.py +829 -0
  38. codex_autorunner/core/sqlite_utils.py +13 -4
  39. codex_autorunner/core/state.py +7 -10
  40. codex_autorunner/core/state_roots.py +5 -0
  41. codex_autorunner/core/templates/__init__.py +39 -0
  42. codex_autorunner/core/templates/git_mirror.py +234 -0
  43. codex_autorunner/core/templates/provenance.py +56 -0
  44. codex_autorunner/core/templates/scan_cache.py +120 -0
  45. codex_autorunner/core/ticket_linter_cli.py +17 -0
  46. codex_autorunner/core/ticket_manager_cli.py +154 -92
  47. codex_autorunner/core/time_utils.py +11 -0
  48. codex_autorunner/core/types.py +18 -0
  49. codex_autorunner/core/utils.py +34 -6
  50. codex_autorunner/flows/review/service.py +23 -25
  51. codex_autorunner/flows/ticket_flow/definition.py +43 -1
  52. codex_autorunner/integrations/agents/__init__.py +2 -0
  53. codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
  54. codex_autorunner/integrations/agents/codex_backend.py +19 -8
  55. codex_autorunner/integrations/agents/runner.py +3 -8
  56. codex_autorunner/integrations/agents/wiring.py +8 -0
  57. codex_autorunner/integrations/telegram/doctor.py +228 -6
  58. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  59. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  60. codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
  61. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  62. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
  63. codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
  64. codex_autorunner/integrations/telegram/handlers/messages.py +26 -1
  65. codex_autorunner/integrations/telegram/helpers.py +1 -3
  66. codex_autorunner/integrations/telegram/runtime.py +9 -4
  67. codex_autorunner/integrations/telegram/service.py +30 -0
  68. codex_autorunner/integrations/telegram/state.py +38 -0
  69. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
  70. codex_autorunner/integrations/telegram/transport.py +10 -3
  71. codex_autorunner/integrations/templates/__init__.py +27 -0
  72. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  73. codex_autorunner/server.py +2 -2
  74. codex_autorunner/static/agentControls.js +21 -5
  75. codex_autorunner/static/app.js +115 -11
  76. codex_autorunner/static/chatUploads.js +137 -0
  77. codex_autorunner/static/docChatCore.js +185 -13
  78. codex_autorunner/static/fileChat.js +68 -40
  79. codex_autorunner/static/fileboxUi.js +159 -0
  80. codex_autorunner/static/hub.js +46 -81
  81. codex_autorunner/static/index.html +303 -24
  82. codex_autorunner/static/messages.js +82 -4
  83. codex_autorunner/static/notifications.js +255 -0
  84. codex_autorunner/static/pma.js +1167 -0
  85. codex_autorunner/static/settings.js +3 -0
  86. codex_autorunner/static/streamUtils.js +57 -0
  87. codex_autorunner/static/styles.css +9125 -6742
  88. codex_autorunner/static/templateReposSettings.js +225 -0
  89. codex_autorunner/static/ticketChatActions.js +165 -3
  90. codex_autorunner/static/ticketChatStream.js +17 -119
  91. codex_autorunner/static/ticketEditor.js +41 -13
  92. codex_autorunner/static/ticketTemplates.js +798 -0
  93. codex_autorunner/static/tickets.js +69 -19
  94. codex_autorunner/static/turnEvents.js +27 -0
  95. codex_autorunner/static/turnResume.js +33 -0
  96. codex_autorunner/static/utils.js +28 -0
  97. codex_autorunner/static/workspace.js +258 -44
  98. codex_autorunner/static/workspaceFileBrowser.js +6 -4
  99. codex_autorunner/surfaces/cli/cli.py +1465 -155
  100. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  101. codex_autorunner/surfaces/web/app.py +253 -49
  102. codex_autorunner/surfaces/web/routes/__init__.py +4 -0
  103. codex_autorunner/surfaces/web/routes/analytics.py +29 -22
  104. codex_autorunner/surfaces/web/routes/file_chat.py +317 -36
  105. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  106. codex_autorunner/surfaces/web/routes/flows.py +219 -29
  107. codex_autorunner/surfaces/web/routes/messages.py +70 -39
  108. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  109. codex_autorunner/surfaces/web/routes/repos.py +1 -1
  110. codex_autorunner/surfaces/web/routes/shared.py +0 -3
  111. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  112. codex_autorunner/surfaces/web/runner_manager.py +2 -2
  113. codex_autorunner/surfaces/web/schemas.py +70 -18
  114. codex_autorunner/tickets/agent_pool.py +27 -0
  115. codex_autorunner/tickets/files.py +33 -16
  116. codex_autorunner/tickets/lint.py +50 -0
  117. codex_autorunner/tickets/models.py +3 -0
  118. codex_autorunner/tickets/outbox.py +41 -5
  119. codex_autorunner/tickets/runner.py +350 -69
  120. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/METADATA +15 -19
  121. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/RECORD +125 -94
  122. codex_autorunner/core/adapter_utils.py +0 -21
  123. codex_autorunner/core/engine.py +0 -3302
  124. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  125. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  126. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  127. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,496 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ from ..bootstrap import ensure_pma_docs
9
+ from ..tickets.files import list_ticket_paths, safe_relpath, ticket_is_done
10
+ from ..tickets.outbox import parse_dispatch, resolve_outbox_paths
11
+ from .config import load_hub_config, load_repo_config
12
+ from .flows.models import FlowRunStatus
13
+ from .flows.store import FlowStore
14
+ from .hub import HubSupervisor
15
+ from .state_roots import resolve_hub_templates_root
16
+
17
+ PMA_MAX_REPOS = 25
18
+ PMA_MAX_MESSAGES = 10
19
+ PMA_MAX_TEXT = 800
20
+ PMA_MAX_TEMPLATE_REPOS = 25
21
+ PMA_MAX_TEMPLATE_FIELD_CHARS = 120
22
+
23
+ # Defaults used when hub config is not available (should be rare).
24
+ PMA_DOCS_MAX_CHARS = 12_000
25
+ PMA_ACTIVE_CONTEXT_MAX_LINES = 200
26
+ PMA_CONTEXT_LOG_TAIL_LINES = 120
27
+
28
+
29
+ def _tail_lines(text: str, max_lines: int) -> str:
30
+ if max_lines <= 0:
31
+ return ""
32
+ lines = (text or "").splitlines()
33
+ if len(lines) <= max_lines:
34
+ return "\n".join(lines)
35
+ return "\n".join(lines[-max_lines:])
36
+
37
+
38
+ def load_pma_workspace_docs(hub_root: Path) -> dict[str, Any]:
39
+ """Load hub-level PMA workspace docs for prompt injection.
40
+
41
+ These docs act as durable memory and working context for PMA.
42
+ """
43
+ try:
44
+ ensure_pma_docs(hub_root)
45
+ except Exception:
46
+ pass
47
+
48
+ docs_max_chars = PMA_DOCS_MAX_CHARS
49
+ active_context_max_lines = PMA_ACTIVE_CONTEXT_MAX_LINES
50
+ context_log_tail_lines = PMA_CONTEXT_LOG_TAIL_LINES
51
+ try:
52
+ hub_config = load_hub_config(hub_root)
53
+ pma_cfg = getattr(hub_config, "pma", None)
54
+ if pma_cfg is not None:
55
+ docs_max_chars = int(getattr(pma_cfg, "docs_max_chars", docs_max_chars))
56
+ active_context_max_lines = int(
57
+ getattr(pma_cfg, "active_context_max_lines", active_context_max_lines)
58
+ )
59
+ context_log_tail_lines = int(
60
+ getattr(pma_cfg, "context_log_tail_lines", context_log_tail_lines)
61
+ )
62
+ except Exception:
63
+ pass
64
+
65
+ pma_dir = hub_root / ".codex-autorunner" / "pma"
66
+ agents_path = pma_dir / "AGENTS.md"
67
+ active_context_path = pma_dir / "active_context.md"
68
+ context_log_path = pma_dir / "context_log.md"
69
+
70
+ def _read(path: Path) -> str:
71
+ try:
72
+ return path.read_text(encoding="utf-8")
73
+ except Exception:
74
+ return ""
75
+
76
+ agents = _truncate(_read(agents_path), docs_max_chars)
77
+ active_context = _read(active_context_path)
78
+ active_context_lines = len((active_context or "").splitlines())
79
+ active_context = _truncate(active_context, docs_max_chars)
80
+ context_log_tail = _tail_lines(_read(context_log_path), context_log_tail_lines)
81
+ context_log_tail = _truncate(context_log_tail, docs_max_chars)
82
+
83
+ return {
84
+ "agents": agents,
85
+ "active_context": active_context,
86
+ "active_context_line_count": active_context_lines,
87
+ "active_context_max_lines": active_context_max_lines,
88
+ "context_log_tail": context_log_tail,
89
+ }
90
+
91
+
92
+ def _truncate(text: Optional[str], limit: int) -> str:
93
+ raw = text or ""
94
+ if len(raw) <= limit:
95
+ return raw
96
+ return raw[: max(0, limit - 3)] + "..."
97
+
98
+
99
+ def _trim_extra(extra: Any, limit: int) -> Any:
100
+ if extra is None:
101
+ return None
102
+ if isinstance(extra, str):
103
+ return _truncate(extra, limit)
104
+ try:
105
+ raw = json.dumps(extra, ensure_ascii=True, sort_keys=True, default=str)
106
+ except Exception:
107
+ raw = str(extra)
108
+ if len(raw) <= limit:
109
+ return extra
110
+ return {
111
+ "_omitted": True,
112
+ "note": "extra omitted due to size",
113
+ "preview": _truncate(raw, limit),
114
+ }
115
+
116
+
117
+ def _load_template_scan_summary(
118
+ hub_root: Optional[Path],
119
+ *,
120
+ max_field_chars: int = PMA_MAX_TEMPLATE_FIELD_CHARS,
121
+ ) -> Optional[dict[str, Any]]:
122
+ if hub_root is None:
123
+ return None
124
+ try:
125
+ scans_root = resolve_hub_templates_root(hub_root) / "scans"
126
+ if not scans_root.exists():
127
+ return None
128
+ candidates = [
129
+ entry
130
+ for entry in scans_root.iterdir()
131
+ if entry.is_file() and entry.suffix == ".json"
132
+ ]
133
+ if not candidates:
134
+ return None
135
+ newest = max(candidates, key=lambda entry: entry.stat().st_mtime)
136
+ payload = json.loads(newest.read_text(encoding="utf-8"))
137
+ if not isinstance(payload, dict):
138
+ return None
139
+ return {
140
+ "repo_id": _truncate(str(payload.get("repo_id", "")), max_field_chars),
141
+ "decision": _truncate(str(payload.get("decision", "")), max_field_chars),
142
+ "severity": _truncate(str(payload.get("severity", "")), max_field_chars),
143
+ "scanned_at": _truncate(
144
+ str(payload.get("scanned_at", "")), max_field_chars
145
+ ),
146
+ }
147
+ except Exception:
148
+ return None
149
+
150
+
151
+ def _build_templates_snapshot(
152
+ supervisor: HubSupervisor,
153
+ *,
154
+ hub_root: Optional[Path] = None,
155
+ max_repos: int = PMA_MAX_TEMPLATE_REPOS,
156
+ max_field_chars: int = PMA_MAX_TEMPLATE_FIELD_CHARS,
157
+ ) -> dict[str, Any]:
158
+ hub_config = getattr(supervisor, "hub_config", None)
159
+ templates_cfg = getattr(hub_config, "templates", None)
160
+ if templates_cfg is None:
161
+ return {"enabled": False, "repos": []}
162
+ repos = []
163
+ for repo in templates_cfg.repos[: max(0, max_repos)]:
164
+ repos.append(
165
+ {
166
+ "id": _truncate(repo.id, max_field_chars),
167
+ "default_ref": _truncate(repo.default_ref, max_field_chars),
168
+ "trusted": bool(repo.trusted),
169
+ }
170
+ )
171
+ payload: dict[str, Any] = {
172
+ "enabled": bool(templates_cfg.enabled),
173
+ "repos": repos,
174
+ }
175
+ scan_summary = _load_template_scan_summary(
176
+ hub_root, max_field_chars=max_field_chars
177
+ )
178
+ if scan_summary:
179
+ payload["last_scan"] = scan_summary
180
+ return payload
181
+
182
+
183
+ def load_pma_prompt(hub_root: Path) -> str:
184
+ path = hub_root / ".codex-autorunner" / "pma" / "prompt.md"
185
+ try:
186
+ ensure_pma_docs(hub_root)
187
+ except Exception:
188
+ pass
189
+ try:
190
+ return path.read_text(encoding="utf-8")
191
+ except Exception:
192
+ return ""
193
+
194
+
195
+ def format_pma_prompt(
196
+ base_prompt: str,
197
+ snapshot: dict[str, Any],
198
+ message: str,
199
+ hub_root: Optional[Path] = None,
200
+ ) -> str:
201
+ snapshot_text = json.dumps(snapshot, sort_keys=True)
202
+
203
+ pma_docs: Optional[dict[str, Any]] = None
204
+ if hub_root is not None:
205
+ try:
206
+ pma_docs = load_pma_workspace_docs(hub_root)
207
+ except Exception:
208
+ pma_docs = None
209
+
210
+ prompt = f"{base_prompt}\n\n"
211
+ prompt += (
212
+ "Ops guide: `.codex-autorunner/pma/ABOUT_CAR.md`.\n"
213
+ "Durable guidance: `.codex-autorunner/pma/AGENTS.md`.\n"
214
+ "Working context: `.codex-autorunner/pma/active_context.md`.\n"
215
+ "History: `.codex-autorunner/pma/context_log.md`.\n"
216
+ "To send a file to the user, write it to `.codex-autorunner/pma/outbox/`.\n"
217
+ "User uploaded files are in `.codex-autorunner/pma/inbox/`.\n\n"
218
+ )
219
+
220
+ if pma_docs:
221
+ max_lines = pma_docs.get("active_context_max_lines")
222
+ line_count = pma_docs.get("active_context_line_count")
223
+ prompt += (
224
+ "<pma_workspace_docs>\n"
225
+ "<AGENTS_MD>\n"
226
+ f"{pma_docs.get('agents', '')}\n"
227
+ "</AGENTS_MD>\n"
228
+ "<ACTIVE_CONTEXT_MD>\n"
229
+ f"{pma_docs.get('active_context', '')}\n"
230
+ "</ACTIVE_CONTEXT_MD>\n"
231
+ f"<ACTIVE_CONTEXT_BUDGET lines='{max_lines}' current_lines='{line_count}' />\n"
232
+ "<CONTEXT_LOG_TAIL_MD>\n"
233
+ f"{pma_docs.get('context_log_tail', '')}\n"
234
+ "</CONTEXT_LOG_TAIL_MD>\n"
235
+ "</pma_workspace_docs>\n\n"
236
+ )
237
+
238
+ prompt += (
239
+ "<hub_snapshot>\n"
240
+ f"{snapshot_text}\n"
241
+ "</hub_snapshot>\n\n"
242
+ "<user_message>\n"
243
+ f"{message}\n"
244
+ "</user_message>\n"
245
+ )
246
+ return prompt
247
+
248
+
249
+ def _get_ticket_flow_summary(repo_path: Path) -> Optional[dict[str, Any]]:
250
+ db_path = repo_path / ".codex-autorunner" / "flows.db"
251
+ if not db_path.exists():
252
+ return None
253
+ try:
254
+ config = load_repo_config(repo_path)
255
+ with FlowStore(db_path, durable=config.durable_writes) as store:
256
+ runs = store.list_flow_runs(flow_type="ticket_flow")
257
+ if not runs:
258
+ return None
259
+ latest = runs[0]
260
+
261
+ ticket_dir = repo_path / ".codex-autorunner" / "tickets"
262
+ total = 0
263
+ done = 0
264
+ for path in list_ticket_paths(ticket_dir):
265
+ total += 1
266
+ try:
267
+ if ticket_is_done(path):
268
+ done += 1
269
+ except Exception:
270
+ continue
271
+
272
+ if total == 0:
273
+ return None
274
+
275
+ state = latest.state if isinstance(latest.state, dict) else {}
276
+ engine = state.get("ticket_engine") if isinstance(state, dict) else {}
277
+ engine = engine if isinstance(engine, dict) else {}
278
+ current_step = engine.get("total_turns")
279
+
280
+ return {
281
+ "status": latest.status.value,
282
+ "done_count": done,
283
+ "total_count": total,
284
+ "current_step": current_step,
285
+ }
286
+ except Exception:
287
+ return None
288
+
289
+
290
+ def _latest_dispatch(
291
+ repo_root: Path, run_id: str, input_data: dict, *, max_text_chars: int
292
+ ) -> Optional[dict[str, Any]]:
293
+ try:
294
+ workspace_root = Path(input_data.get("workspace_root") or repo_root)
295
+ runs_dir = Path(input_data.get("runs_dir") or ".codex-autorunner/runs")
296
+ outbox_paths = resolve_outbox_paths(
297
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
298
+ )
299
+ history_dir = outbox_paths.dispatch_history_dir
300
+ if not history_dir.exists() or not history_dir.is_dir():
301
+ return None
302
+ seq_dirs: list[Path] = []
303
+ for child in history_dir.iterdir():
304
+ if not child.is_dir():
305
+ continue
306
+ name = child.name
307
+ if len(name) == 4 and name.isdigit():
308
+ seq_dirs.append(child)
309
+ if not seq_dirs:
310
+ return None
311
+ latest_dir = sorted(seq_dirs, key=lambda p: p.name)[-1]
312
+ seq = int(latest_dir.name)
313
+ dispatch_path = latest_dir / "DISPATCH.md"
314
+ dispatch, errors = parse_dispatch(dispatch_path)
315
+ if errors or dispatch is None:
316
+ return {
317
+ "seq": seq,
318
+ "dir": safe_relpath(latest_dir, repo_root),
319
+ "dispatch": None,
320
+ "errors": errors,
321
+ "files": [],
322
+ }
323
+ files: list[str] = []
324
+ for child in sorted(latest_dir.iterdir(), key=lambda p: p.name):
325
+ if child.name.startswith("."):
326
+ continue
327
+ if child.name == "DISPATCH.md":
328
+ continue
329
+ if child.is_file():
330
+ files.append(child.name)
331
+ dispatch_dict = {
332
+ "mode": dispatch.mode,
333
+ "title": _truncate(dispatch.title, max_text_chars),
334
+ "body": _truncate(dispatch.body, max_text_chars),
335
+ "extra": _trim_extra(dispatch.extra, max_text_chars),
336
+ "is_handoff": dispatch.is_handoff,
337
+ }
338
+ return {
339
+ "seq": seq,
340
+ "dir": safe_relpath(latest_dir, repo_root),
341
+ "dispatch": dispatch_dict,
342
+ "errors": [],
343
+ "files": files,
344
+ }
345
+ except Exception:
346
+ return None
347
+
348
+
349
+ def _gather_inbox(
350
+ supervisor: HubSupervisor, *, max_text_chars: int
351
+ ) -> list[dict[str, Any]]:
352
+ messages: list[dict[str, Any]] = []
353
+ try:
354
+ snapshots = supervisor.list_repos()
355
+ except Exception:
356
+ return []
357
+ for snap in snapshots:
358
+ if not (snap.initialized and snap.exists_on_disk):
359
+ continue
360
+ repo_root = snap.path
361
+ db_path = repo_root / ".codex-autorunner" / "flows.db"
362
+ if not db_path.exists():
363
+ continue
364
+ try:
365
+ config = load_repo_config(repo_root)
366
+ with FlowStore(db_path, durable=config.durable_writes) as store:
367
+ paused = store.list_flow_runs(
368
+ flow_type="ticket_flow", status=FlowRunStatus.PAUSED
369
+ )
370
+ except Exception:
371
+ continue
372
+ if not paused:
373
+ continue
374
+ for record in paused:
375
+ latest = _latest_dispatch(
376
+ repo_root,
377
+ str(record.id),
378
+ dict(record.input_data or {}),
379
+ max_text_chars=max_text_chars,
380
+ )
381
+ if not latest or not latest.get("dispatch"):
382
+ continue
383
+ messages.append(
384
+ {
385
+ "repo_id": snap.id,
386
+ "repo_display_name": snap.display_name,
387
+ "run_id": record.id,
388
+ "run_created_at": record.created_at,
389
+ "seq": latest["seq"],
390
+ "dispatch": latest["dispatch"],
391
+ "files": latest.get("files") or [],
392
+ "open_url": f"/repos/{snap.id}/?tab=inbox&run_id={record.id}",
393
+ }
394
+ )
395
+ messages.sort(key=lambda m: (m.get("run_created_at") or ""), reverse=True)
396
+ return messages
397
+
398
+
399
+ def _gather_lifecycle_events(
400
+ supervisor: HubSupervisor, limit: int = 20
401
+ ) -> list[dict[str, Any]]:
402
+ events = supervisor.lifecycle_store.get_unprocessed(limit=limit)
403
+ result: list[dict[str, Any]] = []
404
+ for event in events[:limit]:
405
+ result.append(
406
+ {
407
+ "event_type": event.event_type.value,
408
+ "repo_id": event.repo_id,
409
+ "run_id": event.run_id,
410
+ "timestamp": event.timestamp,
411
+ "data": event.data,
412
+ }
413
+ )
414
+ return result
415
+
416
+
417
+ async def build_hub_snapshot(
418
+ supervisor: Optional[HubSupervisor],
419
+ hub_root: Optional[Path] = None,
420
+ ) -> dict[str, Any]:
421
+ if supervisor is None:
422
+ return {
423
+ "repos": [],
424
+ "inbox": [],
425
+ "templates": {"enabled": False, "repos": []},
426
+ "lifecycle_events": [],
427
+ }
428
+
429
+ snapshots = await asyncio.to_thread(supervisor.list_repos)
430
+ snapshots = sorted(snapshots, key=lambda snap: snap.id)
431
+ pma_config = supervisor.hub_config.pma if supervisor else None
432
+ max_repos = (
433
+ pma_config.max_repos
434
+ if pma_config and pma_config.max_repos > 0
435
+ else PMA_MAX_REPOS
436
+ )
437
+ max_messages = (
438
+ pma_config.max_messages
439
+ if pma_config and pma_config.max_messages > 0
440
+ else PMA_MAX_MESSAGES
441
+ )
442
+ max_text_chars = (
443
+ pma_config.max_text_chars
444
+ if pma_config and pma_config.max_text_chars > 0
445
+ else PMA_MAX_TEXT
446
+ )
447
+ repos: list[dict[str, Any]] = []
448
+ for snap in snapshots[:max_repos]:
449
+ summary: dict[str, Any] = {
450
+ "id": snap.id,
451
+ "display_name": snap.display_name,
452
+ "status": snap.status.value,
453
+ "last_run_id": snap.last_run_id,
454
+ "last_run_started_at": snap.last_run_started_at,
455
+ "last_run_finished_at": snap.last_run_finished_at,
456
+ "last_exit_code": snap.last_exit_code,
457
+ "ticket_flow": None,
458
+ }
459
+ if snap.initialized and snap.exists_on_disk:
460
+ summary["ticket_flow"] = _get_ticket_flow_summary(snap.path)
461
+ repos.append(summary)
462
+
463
+ inbox = await asyncio.to_thread(
464
+ _gather_inbox, supervisor, max_text_chars=max_text_chars
465
+ )
466
+ inbox = inbox[:max_messages]
467
+
468
+ lifecycle_events = await asyncio.to_thread(
469
+ _gather_lifecycle_events, supervisor, limit=20
470
+ )
471
+
472
+ templates = _build_templates_snapshot(supervisor, hub_root=hub_root)
473
+
474
+ pma_files: dict[str, list[str]] = {"inbox": [], "outbox": []}
475
+ if hub_root:
476
+ try:
477
+ pma_dir = hub_root / ".codex-autorunner" / "pma"
478
+ for box in ["inbox", "outbox"]:
479
+ box_dir = pma_dir / box
480
+ if box_dir.exists():
481
+ files = [
482
+ f.name
483
+ for f in box_dir.iterdir()
484
+ if f.is_file() and not f.name.startswith(".")
485
+ ]
486
+ pma_files[box] = sorted(files)
487
+ except Exception:
488
+ pass
489
+
490
+ return {
491
+ "repos": repos,
492
+ "inbox": inbox,
493
+ "templates": templates,
494
+ "pma_files": pma_files,
495
+ "lifecycle_events": lifecycle_events,
496
+ }