codex-autorunner 0.1.2__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 (189) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/opencode/client.py +68 -35
  3. codex_autorunner/agents/opencode/logging.py +21 -5
  4. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  5. codex_autorunner/agents/opencode/runtime.py +118 -30
  6. codex_autorunner/agents/opencode/supervisor.py +36 -48
  7. codex_autorunner/agents/registry.py +136 -8
  8. codex_autorunner/api.py +25 -0
  9. codex_autorunner/bootstrap.py +16 -35
  10. codex_autorunner/cli.py +157 -139
  11. codex_autorunner/core/about_car.py +44 -32
  12. codex_autorunner/core/adapter_utils.py +21 -0
  13. codex_autorunner/core/app_server_logging.py +7 -3
  14. codex_autorunner/core/app_server_prompts.py +27 -260
  15. codex_autorunner/core/app_server_threads.py +15 -26
  16. codex_autorunner/core/codex_runner.py +6 -0
  17. codex_autorunner/core/config.py +390 -100
  18. codex_autorunner/core/docs.py +10 -2
  19. codex_autorunner/core/drafts.py +82 -0
  20. codex_autorunner/core/engine.py +278 -262
  21. codex_autorunner/core/flows/__init__.py +25 -0
  22. codex_autorunner/core/flows/controller.py +178 -0
  23. codex_autorunner/core/flows/definition.py +82 -0
  24. codex_autorunner/core/flows/models.py +75 -0
  25. codex_autorunner/core/flows/runtime.py +351 -0
  26. codex_autorunner/core/flows/store.py +485 -0
  27. codex_autorunner/core/flows/transition.py +133 -0
  28. codex_autorunner/core/flows/worker_process.py +242 -0
  29. codex_autorunner/core/hub.py +15 -9
  30. codex_autorunner/core/locks.py +4 -0
  31. codex_autorunner/core/prompt.py +15 -7
  32. codex_autorunner/core/redaction.py +29 -0
  33. codex_autorunner/core/review_context.py +5 -8
  34. codex_autorunner/core/run_index.py +6 -0
  35. codex_autorunner/core/runner_process.py +5 -2
  36. codex_autorunner/core/state.py +0 -88
  37. codex_autorunner/core/static_assets.py +55 -0
  38. codex_autorunner/core/supervisor_utils.py +67 -0
  39. codex_autorunner/core/update.py +20 -11
  40. codex_autorunner/core/update_runner.py +2 -0
  41. codex_autorunner/core/utils.py +29 -2
  42. codex_autorunner/discovery.py +2 -4
  43. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  44. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  45. codex_autorunner/integrations/agents/__init__.py +27 -0
  46. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  47. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  48. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  49. codex_autorunner/integrations/agents/run_event.py +71 -0
  50. codex_autorunner/integrations/app_server/client.py +576 -92
  51. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  52. codex_autorunner/integrations/telegram/adapter.py +141 -167
  53. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  54. codex_autorunner/integrations/telegram/config.py +175 -0
  55. codex_autorunner/integrations/telegram/constants.py +16 -1
  56. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  57. codex_autorunner/integrations/telegram/doctor.py +47 -0
  58. codex_autorunner/integrations/telegram/handlers/callbacks.py +0 -4
  59. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  64. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  65. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  66. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +133 -475
  67. codex_autorunner/integrations/telegram/handlers/commands_spec.py +11 -4
  68. codex_autorunner/integrations/telegram/handlers/messages.py +120 -9
  69. codex_autorunner/integrations/telegram/helpers.py +88 -16
  70. codex_autorunner/integrations/telegram/outbox.py +208 -37
  71. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  72. codex_autorunner/integrations/telegram/service.py +214 -40
  73. codex_autorunner/integrations/telegram/state.py +100 -2
  74. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  75. codex_autorunner/integrations/telegram/transport.py +36 -3
  76. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  77. codex_autorunner/manifest.py +2 -0
  78. codex_autorunner/plugin_api.py +22 -0
  79. codex_autorunner/routes/__init__.py +23 -14
  80. codex_autorunner/routes/analytics.py +239 -0
  81. codex_autorunner/routes/base.py +81 -109
  82. codex_autorunner/routes/file_chat.py +836 -0
  83. codex_autorunner/routes/flows.py +980 -0
  84. codex_autorunner/routes/messages.py +459 -0
  85. codex_autorunner/routes/system.py +6 -1
  86. codex_autorunner/routes/usage.py +87 -0
  87. codex_autorunner/routes/workspace.py +271 -0
  88. codex_autorunner/server.py +2 -1
  89. codex_autorunner/static/agentControls.js +1 -0
  90. codex_autorunner/static/agentEvents.js +248 -0
  91. codex_autorunner/static/app.js +25 -22
  92. codex_autorunner/static/autoRefresh.js +29 -1
  93. codex_autorunner/static/bootstrap.js +1 -0
  94. codex_autorunner/static/bus.js +1 -0
  95. codex_autorunner/static/cache.js +1 -0
  96. codex_autorunner/static/constants.js +20 -4
  97. codex_autorunner/static/dashboard.js +162 -196
  98. codex_autorunner/static/diffRenderer.js +37 -0
  99. codex_autorunner/static/docChatCore.js +324 -0
  100. codex_autorunner/static/docChatStorage.js +65 -0
  101. codex_autorunner/static/docChatVoice.js +65 -0
  102. codex_autorunner/static/docEditor.js +133 -0
  103. codex_autorunner/static/env.js +1 -0
  104. codex_autorunner/static/eventSummarizer.js +166 -0
  105. codex_autorunner/static/fileChat.js +182 -0
  106. codex_autorunner/static/health.js +155 -0
  107. codex_autorunner/static/hub.js +41 -118
  108. codex_autorunner/static/index.html +787 -858
  109. codex_autorunner/static/liveUpdates.js +1 -0
  110. codex_autorunner/static/loader.js +1 -0
  111. codex_autorunner/static/messages.js +470 -0
  112. codex_autorunner/static/mobileCompact.js +2 -1
  113. codex_autorunner/static/settings.js +24 -211
  114. codex_autorunner/static/styles.css +7567 -3865
  115. codex_autorunner/static/tabs.js +28 -5
  116. codex_autorunner/static/terminal.js +14 -0
  117. codex_autorunner/static/terminalManager.js +34 -59
  118. codex_autorunner/static/ticketChatActions.js +333 -0
  119. codex_autorunner/static/ticketChatEvents.js +16 -0
  120. codex_autorunner/static/ticketChatStorage.js +16 -0
  121. codex_autorunner/static/ticketChatStream.js +264 -0
  122. codex_autorunner/static/ticketEditor.js +750 -0
  123. codex_autorunner/static/ticketVoice.js +9 -0
  124. codex_autorunner/static/tickets.js +1315 -0
  125. codex_autorunner/static/utils.js +32 -3
  126. codex_autorunner/static/voice.js +1 -0
  127. codex_autorunner/static/workspace.js +672 -0
  128. codex_autorunner/static/workspaceApi.js +53 -0
  129. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  130. codex_autorunner/tickets/__init__.py +20 -0
  131. codex_autorunner/tickets/agent_pool.py +377 -0
  132. codex_autorunner/tickets/files.py +85 -0
  133. codex_autorunner/tickets/frontmatter.py +55 -0
  134. codex_autorunner/tickets/lint.py +102 -0
  135. codex_autorunner/tickets/models.py +95 -0
  136. codex_autorunner/tickets/outbox.py +232 -0
  137. codex_autorunner/tickets/replies.py +179 -0
  138. codex_autorunner/tickets/runner.py +823 -0
  139. codex_autorunner/tickets/spec_ingest.py +77 -0
  140. codex_autorunner/web/app.py +269 -91
  141. codex_autorunner/web/middleware.py +3 -4
  142. codex_autorunner/web/schemas.py +89 -109
  143. codex_autorunner/web/static_assets.py +1 -44
  144. codex_autorunner/workspace/__init__.py +40 -0
  145. codex_autorunner/workspace/paths.py +319 -0
  146. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +18 -21
  147. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  148. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  149. codex_autorunner/agents/execution/policy.py +0 -292
  150. codex_autorunner/agents/factory.py +0 -52
  151. codex_autorunner/agents/orchestrator.py +0 -358
  152. codex_autorunner/core/doc_chat.py +0 -1446
  153. codex_autorunner/core/snapshot.py +0 -580
  154. codex_autorunner/integrations/github/chatops.py +0 -268
  155. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  156. codex_autorunner/routes/docs.py +0 -381
  157. codex_autorunner/routes/github.py +0 -327
  158. codex_autorunner/routes/runs.py +0 -250
  159. codex_autorunner/spec_ingest.py +0 -812
  160. codex_autorunner/static/docChatActions.js +0 -287
  161. codex_autorunner/static/docChatEvents.js +0 -300
  162. codex_autorunner/static/docChatRender.js +0 -205
  163. codex_autorunner/static/docChatStream.js +0 -361
  164. codex_autorunner/static/docs.js +0 -20
  165. codex_autorunner/static/docsClipboard.js +0 -69
  166. codex_autorunner/static/docsCrud.js +0 -257
  167. codex_autorunner/static/docsDocUpdates.js +0 -62
  168. codex_autorunner/static/docsDrafts.js +0 -16
  169. codex_autorunner/static/docsElements.js +0 -69
  170. codex_autorunner/static/docsInit.js +0 -285
  171. codex_autorunner/static/docsParse.js +0 -160
  172. codex_autorunner/static/docsSnapshot.js +0 -87
  173. codex_autorunner/static/docsSpecIngest.js +0 -263
  174. codex_autorunner/static/docsState.js +0 -127
  175. codex_autorunner/static/docsThreadRegistry.js +0 -44
  176. codex_autorunner/static/docsUi.js +0 -153
  177. codex_autorunner/static/docsVoice.js +0 -56
  178. codex_autorunner/static/github.js +0 -504
  179. codex_autorunner/static/logs.js +0 -678
  180. codex_autorunner/static/review.js +0 -157
  181. codex_autorunner/static/runs.js +0 -418
  182. codex_autorunner/static/snapshot.js +0 -124
  183. codex_autorunner/static/state.js +0 -94
  184. codex_autorunner/static/todoPreview.js +0 -27
  185. codex_autorunner/workspace.py +0 -16
  186. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  187. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  188. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  189. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,459 @@
1
+ """Inbox endpoints for agent dispatches and human replies.
2
+
3
+ These endpoints provide a thin wrapper over the durable on-disk ticket_flow
4
+ dispatch history (agent -> human) and reply history (human -> agent).
5
+
6
+ Domain terminology:
7
+ - Dispatch: Agent-to-human communication (mode: "notify" for FYI, "pause" for handoff)
8
+ - Reply: Human-to-agent response
9
+ - Handoff: A dispatch with mode="pause" that requires human action
10
+
11
+ The UI contract is intentionally filesystem-backed:
12
+ * Dispatches come from `.codex-autorunner/runs/<run_id>/dispatch_history/<seq>/`.
13
+ * Human replies are written to USER_REPLY.md + reply/* and immediately archived
14
+ into `.codex-autorunner/runs/<run_id>/reply_history/<seq>/`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import re
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any, Optional
25
+ from urllib.parse import quote
26
+
27
+ import yaml
28
+ from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
29
+
30
+ from ..core.flows.models import FlowRunRecord, FlowRunStatus
31
+ from ..core.flows.store import FlowStore
32
+ from ..core.utils import find_repo_root
33
+ from ..tickets.files import safe_relpath
34
+ from ..tickets.outbox import parse_dispatch, resolve_outbox_paths
35
+ from ..tickets.replies import (
36
+ dispatch_reply,
37
+ ensure_reply_dirs,
38
+ next_reply_seq,
39
+ parse_user_reply,
40
+ resolve_reply_paths,
41
+ )
42
+
43
+ _logger = logging.getLogger(__name__)
44
+
45
+
46
+ def _flows_db_path(repo_root: Path) -> Path:
47
+ return repo_root / ".codex-autorunner" / "flows.db"
48
+
49
+
50
+ def _load_store_or_404(db_path: Path) -> FlowStore:
51
+ store = FlowStore(db_path)
52
+ try:
53
+ store.initialize()
54
+ return store
55
+ except Exception as exc:
56
+ raise HTTPException(
57
+ status_code=404, detail="Flows database unavailable"
58
+ ) from exc
59
+
60
+
61
+ def _timestamp(path: Path) -> Optional[str]:
62
+ try:
63
+ return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat()
64
+ except OSError:
65
+ return None
66
+
67
+
68
+ def _safe_attachment_name(name: str) -> str:
69
+ base = os.path.basename(name or "").strip()
70
+ if not base:
71
+ raise ValueError("Missing attachment filename")
72
+ if base.lower() == "user_reply.md":
73
+ raise ValueError("Attachment filename reserved: USER_REPLY.md")
74
+ if not re.fullmatch(r"[A-Za-z0-9._-]+", base):
75
+ raise ValueError(
76
+ "Invalid attachment filename; use only letters, digits, dot, underscore, dash"
77
+ )
78
+ return base
79
+
80
+
81
+ def _iter_seq_dirs(history_dir: Path) -> list[tuple[int, Path]]:
82
+ if not history_dir.exists() or not history_dir.is_dir():
83
+ return []
84
+ out: list[tuple[int, Path]] = []
85
+ try:
86
+ for child in history_dir.iterdir():
87
+ try:
88
+ if not child.is_dir():
89
+ continue
90
+ name = child.name
91
+ if not (len(name) == 4 and name.isdigit()):
92
+ continue
93
+ out.append((int(name), child))
94
+ except OSError:
95
+ continue
96
+ except OSError:
97
+ return []
98
+ out.sort(key=lambda x: x[0])
99
+ return out
100
+
101
+
102
+ def _collect_dispatch_history(
103
+ *, repo_root: Path, run_id: str, record_input: dict[str, Any]
104
+ ) -> list[dict[str, Any]]:
105
+ """Collect all dispatches from the dispatch history directory."""
106
+ workspace_root = Path(record_input.get("workspace_root") or repo_root)
107
+ runs_dir = Path(record_input.get("runs_dir") or ".codex-autorunner/runs")
108
+ outbox_paths = resolve_outbox_paths(
109
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
110
+ )
111
+ history: list[dict[str, Any]] = []
112
+ for seq, entry_dir in reversed(_iter_seq_dirs(outbox_paths.dispatch_history_dir)):
113
+ dispatch_path = entry_dir / "DISPATCH.md"
114
+ dispatch, errors = parse_dispatch(dispatch_path)
115
+ files: list[dict[str, str]] = []
116
+ try:
117
+ for child in sorted(entry_dir.iterdir(), key=lambda p: p.name):
118
+ try:
119
+ if child.name.startswith("."):
120
+ continue
121
+ if child.name == "DISPATCH.md":
122
+ continue
123
+ if child.is_dir():
124
+ continue
125
+ rel = child.name
126
+ url = f"api/flows/{run_id}/dispatch_history/{seq:04d}/{quote(rel)}"
127
+ size = None
128
+ try:
129
+ size = child.stat().st_size
130
+ except OSError:
131
+ size = None
132
+ files.append({"name": child.name, "url": url, "size": size})
133
+ except OSError:
134
+ continue
135
+ except OSError:
136
+ files = []
137
+ created_at = _timestamp(dispatch_path) or _timestamp(entry_dir)
138
+ history.append(
139
+ {
140
+ "seq": seq,
141
+ "dir": safe_relpath(entry_dir, workspace_root),
142
+ "created_at": created_at,
143
+ "dispatch": (
144
+ {
145
+ "mode": dispatch.mode,
146
+ "title": dispatch.title,
147
+ "body": dispatch.body,
148
+ "extra": dispatch.extra,
149
+ "is_handoff": dispatch.is_handoff,
150
+ }
151
+ if dispatch
152
+ else None
153
+ ),
154
+ "errors": errors,
155
+ "files": files,
156
+ }
157
+ )
158
+ return history
159
+
160
+
161
+ def _collect_reply_history(
162
+ *, repo_root: Path, run_id: str, record_input: dict[str, Any]
163
+ ):
164
+ workspace_root = Path(record_input.get("workspace_root") or repo_root)
165
+ runs_dir = Path(record_input.get("runs_dir") or ".codex-autorunner/runs")
166
+ reply_paths = resolve_reply_paths(
167
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
168
+ )
169
+ history: list[dict[str, Any]] = []
170
+ for seq, entry_dir in reversed(_iter_seq_dirs(reply_paths.reply_history_dir)):
171
+ reply_path = entry_dir / "USER_REPLY.md"
172
+ reply, errors = (
173
+ parse_user_reply(reply_path)
174
+ if reply_path.exists()
175
+ else (None, ["USER_REPLY.md missing"])
176
+ )
177
+ files: list[dict[str, str]] = []
178
+ try:
179
+ for child in sorted(entry_dir.iterdir(), key=lambda p: p.name):
180
+ try:
181
+ if child.name.startswith("."):
182
+ continue
183
+ if child.name == "USER_REPLY.md":
184
+ continue
185
+ if child.is_dir():
186
+ continue
187
+ rel = child.name
188
+ url = f"api/flows/{run_id}/reply_history/{seq:04d}/{quote(rel)}"
189
+ size = None
190
+ try:
191
+ size = child.stat().st_size
192
+ except OSError:
193
+ size = None
194
+ files.append({"name": child.name, "url": url, "size": size})
195
+ except OSError:
196
+ continue
197
+ except OSError:
198
+ files = []
199
+ created_at = _timestamp(reply_path) or _timestamp(entry_dir)
200
+ history.append(
201
+ {
202
+ "seq": seq,
203
+ "dir": safe_relpath(entry_dir, workspace_root),
204
+ "created_at": created_at,
205
+ "reply": (
206
+ {"title": reply.title, "body": reply.body, "extra": reply.extra}
207
+ if reply
208
+ else None
209
+ ),
210
+ "errors": errors,
211
+ "files": files,
212
+ }
213
+ )
214
+ return history
215
+
216
+
217
+ def _ticket_state_snapshot(record: FlowRunRecord) -> dict[str, Any]:
218
+ state = record.state if isinstance(record.state, dict) else {}
219
+ ticket_state = state.get("ticket_engine") if isinstance(state, dict) else {}
220
+ if not isinstance(ticket_state, dict):
221
+ ticket_state = {}
222
+ allowed_keys = {
223
+ "current_ticket",
224
+ "total_turns",
225
+ "ticket_turns",
226
+ "dispatch_seq",
227
+ "reply_seq",
228
+ "reason",
229
+ "status",
230
+ }
231
+ return {k: ticket_state.get(k) for k in allowed_keys if k in ticket_state}
232
+
233
+
234
+ def build_messages_routes() -> APIRouter:
235
+ router = APIRouter()
236
+
237
+ @router.get("/api/messages/active")
238
+ def get_active_message(request: Request):
239
+ repo_root = find_repo_root()
240
+ db_path = _flows_db_path(repo_root)
241
+ if not db_path.exists():
242
+ return {"active": False}
243
+ store = FlowStore(db_path)
244
+ try:
245
+ store.initialize()
246
+ except Exception:
247
+ # Corrupt flows db should not 500 the UI.
248
+ return {"active": False}
249
+
250
+ paused = store.list_flow_runs(
251
+ flow_type="ticket_flow", status=FlowRunStatus.PAUSED
252
+ )
253
+ if not paused:
254
+ return {"active": False}
255
+
256
+ # Walk paused runs (newest first as returned by FlowStore) until we find
257
+ # one with at least one archived dispatch. This avoids hiding
258
+ # older paused runs that do have history when the newest paused run
259
+ # hasn't yet written DISPATCH.md.
260
+ for record in paused:
261
+ history = _collect_dispatch_history(
262
+ repo_root=repo_root,
263
+ run_id=str(record.id),
264
+ record_input=dict(record.input_data or {}),
265
+ )
266
+ if not history:
267
+ continue
268
+ latest = history[0]
269
+ return {
270
+ "active": True,
271
+ "run_id": record.id,
272
+ "flow_type": record.flow_type,
273
+ "status": record.status.value,
274
+ "seq": latest.get("seq"),
275
+ "dispatch": latest.get("dispatch"),
276
+ "files": latest.get("files"),
277
+ "open_url": f"?tab=inbox&run_id={record.id}",
278
+ }
279
+
280
+ return {"active": False}
281
+
282
+ @router.get("/api/messages/threads")
283
+ def list_threads():
284
+ repo_root = find_repo_root()
285
+ db_path = _flows_db_path(repo_root)
286
+ if not db_path.exists():
287
+ return {"conversations": []}
288
+ store = FlowStore(db_path)
289
+ try:
290
+ store.initialize()
291
+ except Exception:
292
+ return {"conversations": []}
293
+ runs = store.list_flow_runs(flow_type="ticket_flow")
294
+ conversations: list[dict[str, Any]] = []
295
+ for record in runs:
296
+ record_input = dict(record.input_data or {})
297
+ dispatch_history = _collect_dispatch_history(
298
+ repo_root=repo_root,
299
+ run_id=str(record.id),
300
+ record_input=record_input,
301
+ )
302
+ if not dispatch_history:
303
+ continue
304
+ latest = dispatch_history[0]
305
+ reply_history = _collect_reply_history(
306
+ repo_root=repo_root,
307
+ run_id=str(record.id),
308
+ record_input=record_input,
309
+ )
310
+ conversations.append(
311
+ {
312
+ "run_id": record.id,
313
+ "flow_type": record.flow_type,
314
+ "status": record.status.value,
315
+ "created_at": record.created_at,
316
+ "started_at": record.started_at,
317
+ "finished_at": record.finished_at,
318
+ "current_step": record.current_step,
319
+ "latest": latest,
320
+ "dispatch_count": len(dispatch_history),
321
+ "reply_count": len(reply_history),
322
+ "ticket_state": _ticket_state_snapshot(record),
323
+ "open_url": f"?tab=inbox&run_id={record.id}",
324
+ }
325
+ )
326
+ return {"conversations": conversations}
327
+
328
+ @router.get("/api/messages/threads/{run_id}")
329
+ def get_thread(run_id: str):
330
+ repo_root = find_repo_root()
331
+ db_path = _flows_db_path(repo_root)
332
+ empty_response = {
333
+ "dispatch_history": [],
334
+ "reply_history": [],
335
+ "dispatch_count": 0,
336
+ "reply_count": 0,
337
+ }
338
+ if not db_path.exists():
339
+ return empty_response
340
+ store = _load_store_or_404(db_path)
341
+ try:
342
+ record = store.get_flow_run(run_id)
343
+ finally:
344
+ try:
345
+ store.close()
346
+ except Exception:
347
+ pass
348
+ if not record:
349
+ return empty_response
350
+ input_data = dict(record.input_data or {})
351
+ dispatch_history = _collect_dispatch_history(
352
+ repo_root=repo_root, run_id=run_id, record_input=input_data
353
+ )
354
+ reply_history = _collect_reply_history(
355
+ repo_root=repo_root, run_id=run_id, record_input=input_data
356
+ )
357
+ return {
358
+ "run": {
359
+ "id": record.id,
360
+ "flow_type": record.flow_type,
361
+ "status": record.status.value,
362
+ "created_at": record.created_at,
363
+ "started_at": record.started_at,
364
+ "finished_at": record.finished_at,
365
+ "current_step": record.current_step,
366
+ "error_message": record.error_message,
367
+ },
368
+ "dispatch_history": dispatch_history,
369
+ "reply_history": reply_history,
370
+ "dispatch_count": len(dispatch_history),
371
+ "reply_count": len(reply_history),
372
+ "ticket_state": _ticket_state_snapshot(record),
373
+ }
374
+
375
+ @router.post("/api/messages/{run_id}/reply")
376
+ async def post_reply(
377
+ run_id: str,
378
+ body: str = Form(""),
379
+ title: Optional[str] = Form(None),
380
+ # NOTE: FastAPI/starlette will supply either a single UploadFile or a list
381
+ # depending on how the multipart form is encoded. Declaring this as a
382
+ # concrete list avoids a common 422 where a single file upload is treated
383
+ # as a non-list value.
384
+ files: list[UploadFile] = File(default=[]), # noqa: B006,B008
385
+ ):
386
+ repo_root = find_repo_root()
387
+ db_path = _flows_db_path(repo_root)
388
+ if not db_path.exists():
389
+ raise HTTPException(status_code=404, detail="No flows database")
390
+ store = _load_store_or_404(db_path)
391
+ try:
392
+ record = store.get_flow_run(run_id)
393
+ finally:
394
+ try:
395
+ store.close()
396
+ except Exception:
397
+ pass
398
+ if not record:
399
+ raise HTTPException(status_code=404, detail="Run not found")
400
+
401
+ input_data = dict(record.input_data or {})
402
+ workspace_root = Path(input_data.get("workspace_root") or repo_root)
403
+ runs_dir = Path(input_data.get("runs_dir") or ".codex-autorunner/runs")
404
+ reply_paths = resolve_reply_paths(
405
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
406
+ )
407
+ ensure_reply_dirs(reply_paths)
408
+
409
+ cleaned_title = (
410
+ title.strip() if isinstance(title, str) and title.strip() else None
411
+ )
412
+ cleaned_body = body or ""
413
+
414
+ if cleaned_title:
415
+ fm = yaml.safe_dump({"title": cleaned_title}, sort_keys=False).strip()
416
+ raw = f"---\n{fm}\n---\n\n{cleaned_body}\n"
417
+ else:
418
+ raw = cleaned_body
419
+ if raw and not raw.endswith("\n"):
420
+ raw += "\n"
421
+
422
+ try:
423
+ reply_paths.user_reply_path.parent.mkdir(parents=True, exist_ok=True)
424
+ reply_paths.user_reply_path.write_text(raw, encoding="utf-8")
425
+ except OSError as exc:
426
+ raise HTTPException(
427
+ status_code=500, detail=f"Failed to write USER_REPLY.md: {exc}"
428
+ ) from exc
429
+
430
+ for upload in files:
431
+ try:
432
+ filename = _safe_attachment_name(upload.filename or "")
433
+ except ValueError as exc:
434
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
435
+ dest = reply_paths.reply_dir / filename
436
+ data = await upload.read()
437
+ try:
438
+ dest.write_bytes(data)
439
+ except OSError as exc:
440
+ raise HTTPException(
441
+ status_code=500, detail=f"Failed to write attachment: {exc}"
442
+ ) from exc
443
+
444
+ seq = next_reply_seq(reply_paths.reply_history_dir)
445
+ dispatch, errors = dispatch_reply(reply_paths, next_seq=seq)
446
+ if errors:
447
+ raise HTTPException(status_code=400, detail=errors)
448
+ if dispatch is None:
449
+ raise HTTPException(status_code=500, detail="Failed to archive reply")
450
+ return {
451
+ "status": "ok",
452
+ "seq": dispatch.seq,
453
+ "reply": {"title": dispatch.reply.title, "body": dispatch.reply.body},
454
+ }
455
+
456
+ return router
457
+
458
+
459
+ __all__ = ["build_messages_routes"]
@@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse
8
8
 
9
9
  from ..core import update as update_core
10
10
  from ..core.config import HubConfig
11
+ from ..core.static_assets import missing_static_assets
11
12
  from ..core.update import (
12
13
  UpdateInProgressError,
13
14
  _normalize_update_ref,
@@ -23,7 +24,6 @@ from ..web.schemas import (
23
24
  SystemUpdateResponse,
24
25
  SystemUpdateStatusResponse,
25
26
  )
26
- from ..web.static_assets import missing_static_assets
27
27
  from ..web.static_refresh import refresh_static_assets
28
28
 
29
29
  _pid_is_running = update_core._pid_is_running
@@ -139,6 +139,7 @@ def build_system_routes() -> APIRouter:
139
139
  # Determine URL
140
140
  repo_url = "https://github.com/Git-on-my-level/codex-autorunner.git"
141
141
  repo_ref = "main"
142
+ skip_checks = False
142
143
  if config and isinstance(config, HubConfig):
143
144
  configured_url = getattr(config, "update_repo_url", None)
144
145
  if configured_url:
@@ -146,6 +147,9 @@ def build_system_routes() -> APIRouter:
146
147
  configured_ref = getattr(config, "update_repo_ref", None)
147
148
  if configured_ref:
148
149
  repo_ref = configured_ref
150
+ skip_checks = bool(getattr(config, "update_skip_checks", False))
151
+ elif config is not None:
152
+ skip_checks = bool(getattr(config, "update_skip_checks", False))
149
153
 
150
154
  home_dot_car = Path.home() / ".codex-autorunner"
151
155
  update_dir = home_dot_car / "update_cache"
@@ -165,6 +169,7 @@ def build_system_routes() -> APIRouter:
165
169
  update_dir=update_dir,
166
170
  logger=logger,
167
171
  update_target=update_target,
172
+ skip_checks=skip_checks,
168
173
  )
169
174
  return {
170
175
  "status": "ok",
@@ -0,0 +1,87 @@
1
+ """
2
+ Usage routes: token usage summaries for repo/hub.
3
+
4
+ Moved out of the legacy docs routes during the workspace + file chat cutover.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ from fastapi import APIRouter, HTTPException, Request
12
+
13
+ from ..core.usage import (
14
+ UsageError,
15
+ default_codex_home,
16
+ get_repo_usage_series_cached,
17
+ get_repo_usage_summary_cached,
18
+ parse_iso_datetime,
19
+ )
20
+ from ..web.schemas import RepoUsageResponse, UsageSeriesResponse
21
+
22
+
23
+ def build_usage_routes() -> APIRouter:
24
+ router = APIRouter(prefix="/api", tags=["usage"])
25
+
26
+ @router.get("/usage", response_model=RepoUsageResponse)
27
+ def get_usage(
28
+ request: Request, since: Optional[str] = None, until: Optional[str] = None
29
+ ):
30
+ engine = request.app.state.engine
31
+ try:
32
+ since_dt = parse_iso_datetime(since)
33
+ until_dt = parse_iso_datetime(until)
34
+ except UsageError as exc:
35
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
36
+ summary, status = get_repo_usage_summary_cached(
37
+ engine.repo_root,
38
+ default_codex_home(),
39
+ since=since_dt,
40
+ until=until_dt,
41
+ )
42
+ return {
43
+ "mode": "repo",
44
+ "repo": str(engine.repo_root),
45
+ "codex_home": str(default_codex_home()),
46
+ "since": since,
47
+ "until": until,
48
+ "status": status,
49
+ **summary.to_dict(),
50
+ }
51
+
52
+ @router.get("/usage/series", response_model=UsageSeriesResponse)
53
+ def get_usage_series(
54
+ request: Request,
55
+ since: Optional[str] = None,
56
+ until: Optional[str] = None,
57
+ bucket: str = "day",
58
+ segment: str = "none",
59
+ ):
60
+ engine = request.app.state.engine
61
+ try:
62
+ since_dt = parse_iso_datetime(since)
63
+ until_dt = parse_iso_datetime(until)
64
+ except UsageError as exc:
65
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
66
+ try:
67
+ series, status = get_repo_usage_series_cached(
68
+ engine.repo_root,
69
+ default_codex_home(),
70
+ since=since_dt,
71
+ until=until_dt,
72
+ bucket=bucket,
73
+ segment=segment,
74
+ )
75
+ except UsageError as exc:
76
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
77
+ return {
78
+ "mode": "repo",
79
+ "repo": str(engine.repo_root),
80
+ "codex_home": str(default_codex_home()),
81
+ "since": since,
82
+ "until": until,
83
+ "status": status,
84
+ **series,
85
+ }
86
+
87
+ return router