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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/constants.py +3 -0
  4. codex_autorunner/agents/opencode/harness.py +6 -1
  5. codex_autorunner/agents/opencode/runtime.py +59 -18
  6. codex_autorunner/agents/registry.py +22 -3
  7. codex_autorunner/bootstrap.py +7 -3
  8. codex_autorunner/cli.py +5 -1174
  9. codex_autorunner/codex_cli.py +20 -84
  10. codex_autorunner/core/__init__.py +4 -0
  11. codex_autorunner/core/about_car.py +6 -1
  12. codex_autorunner/core/app_server_ids.py +59 -0
  13. codex_autorunner/core/app_server_threads.py +11 -2
  14. codex_autorunner/core/app_server_utils.py +165 -0
  15. codex_autorunner/core/archive.py +349 -0
  16. codex_autorunner/core/codex_runner.py +6 -2
  17. codex_autorunner/core/config.py +197 -3
  18. codex_autorunner/core/drafts.py +58 -4
  19. codex_autorunner/core/engine.py +1329 -680
  20. codex_autorunner/core/exceptions.py +4 -0
  21. codex_autorunner/core/flows/controller.py +25 -1
  22. codex_autorunner/core/flows/models.py +13 -0
  23. codex_autorunner/core/flows/reasons.py +52 -0
  24. codex_autorunner/core/flows/reconciler.py +131 -0
  25. codex_autorunner/core/flows/runtime.py +35 -4
  26. codex_autorunner/core/flows/store.py +83 -0
  27. codex_autorunner/core/flows/transition.py +5 -0
  28. codex_autorunner/core/flows/ux_helpers.py +257 -0
  29. codex_autorunner/core/git_utils.py +62 -0
  30. codex_autorunner/core/hub.py +121 -7
  31. codex_autorunner/core/notifications.py +14 -2
  32. codex_autorunner/core/ports/__init__.py +28 -0
  33. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +11 -3
  34. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  35. codex_autorunner/{integrations/agents → core/ports}/run_event.py +22 -2
  36. codex_autorunner/core/state_roots.py +57 -0
  37. codex_autorunner/core/supervisor_protocol.py +15 -0
  38. codex_autorunner/core/text_delta_coalescer.py +54 -0
  39. codex_autorunner/core/ticket_linter_cli.py +201 -0
  40. codex_autorunner/core/ticket_manager_cli.py +432 -0
  41. codex_autorunner/core/update.py +4 -5
  42. codex_autorunner/core/update_paths.py +28 -0
  43. codex_autorunner/core/usage.py +164 -12
  44. codex_autorunner/core/utils.py +91 -9
  45. codex_autorunner/flows/review/__init__.py +17 -0
  46. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  47. codex_autorunner/flows/ticket_flow/definition.py +9 -2
  48. codex_autorunner/integrations/agents/__init__.py +9 -19
  49. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  50. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  51. codex_autorunner/integrations/agents/codex_backend.py +158 -17
  52. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  53. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  54. codex_autorunner/integrations/agents/runner.py +91 -0
  55. codex_autorunner/integrations/agents/wiring.py +271 -0
  56. codex_autorunner/integrations/app_server/client.py +7 -60
  57. codex_autorunner/integrations/app_server/env.py +2 -107
  58. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  59. codex_autorunner/integrations/telegram/adapter.py +65 -0
  60. codex_autorunner/integrations/telegram/config.py +46 -0
  61. codex_autorunner/integrations/telegram/constants.py +1 -1
  62. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1203 -66
  64. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +4 -3
  65. codex_autorunner/integrations/telegram/handlers/commands_spec.py +8 -2
  66. codex_autorunner/integrations/telegram/handlers/messages.py +1 -0
  67. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  68. codex_autorunner/integrations/telegram/helpers.py +24 -1
  69. codex_autorunner/integrations/telegram/service.py +15 -10
  70. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +329 -40
  71. codex_autorunner/integrations/telegram/transport.py +3 -1
  72. codex_autorunner/routes/__init__.py +37 -76
  73. codex_autorunner/routes/agents.py +2 -137
  74. codex_autorunner/routes/analytics.py +2 -238
  75. codex_autorunner/routes/app_server.py +2 -131
  76. codex_autorunner/routes/base.py +2 -596
  77. codex_autorunner/routes/file_chat.py +4 -833
  78. codex_autorunner/routes/flows.py +4 -977
  79. codex_autorunner/routes/messages.py +4 -456
  80. codex_autorunner/routes/repos.py +2 -196
  81. codex_autorunner/routes/review.py +2 -147
  82. codex_autorunner/routes/sessions.py +2 -175
  83. codex_autorunner/routes/settings.py +2 -168
  84. codex_autorunner/routes/shared.py +2 -275
  85. codex_autorunner/routes/system.py +4 -193
  86. codex_autorunner/routes/usage.py +2 -86
  87. codex_autorunner/routes/voice.py +2 -119
  88. codex_autorunner/routes/workspace.py +2 -270
  89. codex_autorunner/server.py +2 -2
  90. codex_autorunner/static/agentControls.js +40 -11
  91. codex_autorunner/static/app.js +11 -3
  92. codex_autorunner/static/archive.js +826 -0
  93. codex_autorunner/static/archiveApi.js +37 -0
  94. codex_autorunner/static/autoRefresh.js +7 -7
  95. codex_autorunner/static/dashboard.js +224 -171
  96. codex_autorunner/static/hub.js +112 -94
  97. codex_autorunner/static/index.html +80 -33
  98. codex_autorunner/static/messages.js +486 -83
  99. codex_autorunner/static/preserve.js +17 -0
  100. codex_autorunner/static/settings.js +125 -6
  101. codex_autorunner/static/smartRefresh.js +52 -0
  102. codex_autorunner/static/styles.css +1373 -101
  103. codex_autorunner/static/tabs.js +152 -11
  104. codex_autorunner/static/terminal.js +18 -0
  105. codex_autorunner/static/ticketEditor.js +99 -5
  106. codex_autorunner/static/tickets.js +760 -87
  107. codex_autorunner/static/utils.js +11 -0
  108. codex_autorunner/static/workspace.js +133 -40
  109. codex_autorunner/static/workspaceFileBrowser.js +9 -9
  110. codex_autorunner/surfaces/__init__.py +5 -0
  111. codex_autorunner/surfaces/cli/__init__.py +6 -0
  112. codex_autorunner/surfaces/cli/cli.py +1224 -0
  113. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  114. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  115. codex_autorunner/surfaces/web/__init__.py +1 -0
  116. codex_autorunner/surfaces/web/app.py +2019 -0
  117. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  118. codex_autorunner/surfaces/web/middleware.py +587 -0
  119. codex_autorunner/surfaces/web/pty_session.py +370 -0
  120. codex_autorunner/surfaces/web/review.py +6 -0
  121. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  122. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  123. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  124. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  125. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  126. codex_autorunner/surfaces/web/routes/base.py +615 -0
  127. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  128. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  129. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  130. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  131. codex_autorunner/surfaces/web/routes/review.py +148 -0
  132. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  133. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  134. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  135. codex_autorunner/surfaces/web/routes/system.py +196 -0
  136. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  137. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  138. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  139. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  140. codex_autorunner/surfaces/web/schemas.py +417 -0
  141. codex_autorunner/surfaces/web/static_assets.py +490 -0
  142. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  143. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  144. codex_autorunner/tickets/__init__.py +8 -1
  145. codex_autorunner/tickets/agent_pool.py +26 -4
  146. codex_autorunner/tickets/files.py +6 -2
  147. codex_autorunner/tickets/models.py +3 -1
  148. codex_autorunner/tickets/outbox.py +12 -0
  149. codex_autorunner/tickets/runner.py +63 -5
  150. codex_autorunner/web/__init__.py +5 -1
  151. codex_autorunner/web/app.py +2 -1949
  152. codex_autorunner/web/hub_jobs.py +2 -191
  153. codex_autorunner/web/middleware.py +2 -586
  154. codex_autorunner/web/pty_session.py +2 -369
  155. codex_autorunner/web/runner_manager.py +2 -24
  156. codex_autorunner/web/schemas.py +2 -376
  157. codex_autorunner/web/static_assets.py +4 -441
  158. codex_autorunner/web/static_refresh.py +2 -85
  159. codex_autorunner/web/terminal_sessions.py +2 -77
  160. codex_autorunner/workspace/paths.py +49 -33
  161. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  162. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  163. codex_autorunner/core/static_assets.py +0 -55
  164. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  165. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  166. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  167. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +0 -0
  168. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  169. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  170. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,277 @@
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 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 _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 _aggregate_diff_stats(dispatch_history_dir: Path) -> Dict[str, int]:
121
+ """Aggregate diff stats from all turn summaries in dispatch history.
122
+
123
+ Returns dict with insertions, deletions, files_changed totals.
124
+ """
125
+ totals = {"insertions": 0, "deletions": 0, "files_changed": 0}
126
+ if not dispatch_history_dir.exists() or not dispatch_history_dir.is_dir():
127
+ return totals
128
+
129
+ try:
130
+ for entry_dir in dispatch_history_dir.iterdir():
131
+ if not entry_dir.is_dir():
132
+ continue
133
+ if not (len(entry_dir.name) == 4 and entry_dir.name.isdigit()):
134
+ continue
135
+ dispatch_path = entry_dir / "DISPATCH.md"
136
+ if not dispatch_path.exists():
137
+ continue
138
+ try:
139
+ dispatch, _errors = parse_dispatch(dispatch_path)
140
+ if dispatch and dispatch.extra:
141
+ diff_stats = dispatch.extra.get("diff_stats")
142
+ if isinstance(diff_stats, dict):
143
+ totals["insertions"] += int(diff_stats.get("insertions") or 0)
144
+ totals["deletions"] += int(diff_stats.get("deletions") or 0)
145
+ totals["files_changed"] += int(
146
+ diff_stats.get("files_changed") or 0
147
+ )
148
+ except Exception:
149
+ continue
150
+ except OSError:
151
+ pass
152
+
153
+ return totals
154
+
155
+
156
+ def _build_summary(repo_root: Path) -> Dict[str, Any]:
157
+ ticket_dir = repo_root / ".codex-autorunner" / "tickets"
158
+ store = _load_flow_store(repo_root)
159
+ records: list[FlowRunRecord] = []
160
+ if store:
161
+ try:
162
+ records = store.list_flow_runs(flow_type="ticket_flow")
163
+ except Exception:
164
+ records = []
165
+ finally:
166
+ try:
167
+ store.close()
168
+ except Exception:
169
+ pass
170
+
171
+ run_record = _select_primary_run(records)
172
+
173
+ default_run = {
174
+ "id": None,
175
+ "short_id": None,
176
+ "status": "idle",
177
+ "started_at": None,
178
+ "finished_at": None,
179
+ "duration_seconds": None,
180
+ "current_step": None,
181
+ "created_at": None,
182
+ }
183
+
184
+ run_data: Dict[str, Any] = default_run
185
+ turns: Dict[str, Optional[int]] = {
186
+ "total": None,
187
+ "current_ticket": None,
188
+ "dispatches": 0,
189
+ "replies": 0,
190
+ }
191
+ current_ticket: Optional[str] = None
192
+ agent_id: Optional[str] = None
193
+
194
+ if run_record:
195
+ run_data = {
196
+ "id": run_record.id,
197
+ "short_id": run_record.id.split("-")[0] if run_record.id else None,
198
+ "status": run_record.status.value,
199
+ "started_at": run_record.started_at,
200
+ "finished_at": run_record.finished_at,
201
+ "duration_seconds": _duration_seconds(
202
+ run_record.started_at, run_record.finished_at, run_record.status.value
203
+ ),
204
+ "current_step": run_record.current_step,
205
+ "created_at": run_record.created_at,
206
+ }
207
+
208
+ state = run_record.state if isinstance(run_record.state, dict) else {}
209
+ ticket_state = state.get("ticket_engine") if isinstance(state, dict) else {}
210
+ if isinstance(ticket_state, dict):
211
+ turns["total"] = ticket_state.get("total_turns") # type: ignore[index]
212
+ turns["current_ticket"] = ticket_state.get("ticket_turns") # type: ignore[index]
213
+ current_ticket = ticket_state.get("current_ticket") # type: ignore[assignment]
214
+ agent_id = ticket_state.get("last_agent_id") # type: ignore[assignment]
215
+
216
+ workspace_value = run_record.input_data.get("workspace_root")
217
+ workspace_root = Path(workspace_value) if workspace_value else repo_root
218
+ runs_dir = Path(
219
+ run_record.input_data.get("runs_dir") or ".codex-autorunner/runs"
220
+ )
221
+ outbox_paths = resolve_outbox_paths(
222
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_record.id
223
+ )
224
+ reply_paths = resolve_reply_paths(
225
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_record.id
226
+ )
227
+ turns["dispatches"] = _count_history_dirs(outbox_paths.dispatch_history_dir)
228
+ turns["replies"] = _count_history_dirs(reply_paths.reply_history_dir)
229
+ turns["diff_stats"] = _aggregate_diff_stats(outbox_paths.dispatch_history_dir)
230
+
231
+ # If current ticket is known, read its frontmatter to pick agent id when available.
232
+ if current_ticket:
233
+ current_path = (workspace_root / current_ticket).resolve()
234
+ try:
235
+ doc, _errors = read_ticket(current_path)
236
+ if doc and doc.frontmatter and getattr(doc.frontmatter, "agent", None):
237
+ agent_id = doc.frontmatter.agent
238
+ except Exception:
239
+ pass
240
+
241
+ ticket_counts = _ticket_counts(ticket_dir)
242
+
243
+ return {
244
+ "run": run_data,
245
+ "tickets": {
246
+ "todo_count": ticket_counts["todo"],
247
+ "done_count": ticket_counts["done"],
248
+ "total_count": ticket_counts["total"],
249
+ "current_ticket": current_ticket,
250
+ },
251
+ "turns": {
252
+ "total": turns.get("total"),
253
+ "current_ticket": turns.get("current_ticket"),
254
+ "dispatches": turns.get("dispatches"),
255
+ "replies": turns.get("replies"),
256
+ "diff_stats": turns.get("diff_stats"),
257
+ },
258
+ "agent": {
259
+ "id": agent_id,
260
+ "model": None,
261
+ },
262
+ }
263
+
264
+
265
+ def build_analytics_routes() -> APIRouter:
266
+ router = APIRouter(prefix="/api/analytics", tags=["analytics"])
267
+
268
+ @router.get("/summary")
269
+ def get_analytics_summary():
270
+ repo_root = find_repo_root()
271
+ data = _build_summary(repo_root)
272
+ return data
273
+
274
+ return router
275
+
276
+
277
+ __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