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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,357 @@
1
+ """Archive browsing routes for repo-mode servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path, PurePosixPath
10
+ from typing import Any, Optional
11
+
12
+ from fastapi import APIRouter, HTTPException, Request
13
+ from fastapi.responses import FileResponse, PlainTextResponse
14
+
15
+ from ..schemas import (
16
+ ArchiveSnapshotDetailResponse,
17
+ ArchiveSnapshotsResponse,
18
+ ArchiveSnapshotSummary,
19
+ ArchiveTreeResponse,
20
+ )
21
+
22
+ logger = logging.getLogger("codex_autorunner.routes.archive")
23
+
24
+ _DRIVE_PREFIX_RE = re.compile(r"^[A-Za-z]:")
25
+
26
+
27
+ def _archive_worktrees_root(repo_root: Path) -> Path:
28
+ return repo_root / ".codex-autorunner" / "archive" / "worktrees"
29
+
30
+
31
+ def _normalize_component(value: str, label: str) -> str:
32
+ cleaned = (value or "").strip()
33
+ if not cleaned:
34
+ raise ValueError(f"missing {label}")
35
+ if "\\" in cleaned:
36
+ raise ValueError(f"invalid {label}")
37
+ if _DRIVE_PREFIX_RE.match(cleaned):
38
+ raise ValueError(f"invalid {label}")
39
+ path = PurePosixPath(cleaned)
40
+ if path.is_absolute() or ".." in path.parts:
41
+ raise ValueError(f"invalid {label}")
42
+ if len(path.parts) != 1:
43
+ raise ValueError(f"invalid {label}")
44
+ if path.name in {".", ".."}:
45
+ raise ValueError(f"invalid {label}")
46
+ return path.name
47
+
48
+
49
+ def _normalize_archive_rel_path(base: Path, rel_path: str) -> tuple[Path, str]:
50
+ cleaned = (rel_path or "").strip()
51
+ if not cleaned:
52
+ return base, ""
53
+ if "\\" in cleaned:
54
+ raise ValueError("invalid archive path")
55
+ if _DRIVE_PREFIX_RE.match(cleaned):
56
+ raise ValueError("invalid archive path")
57
+ relative = PurePosixPath(cleaned)
58
+ if relative.is_absolute() or ".." in relative.parts:
59
+ raise ValueError("invalid archive path")
60
+ base_real = base.resolve(strict=False)
61
+ candidate = (base / relative).resolve(
62
+ strict=False
63
+ ) # codeql[py/path-injection] base is validated snapshot root
64
+ try:
65
+ rel_posix = candidate.relative_to(base_real).as_posix()
66
+ except ValueError:
67
+ raise ValueError("invalid archive path") from None
68
+ return candidate, rel_posix
69
+
70
+
71
+ def _resolve_snapshot_root(
72
+ repo_root: Path,
73
+ snapshot_id: str,
74
+ worktree_repo_id: Optional[str] = None,
75
+ ) -> tuple[Path, str]:
76
+ snapshot_id = _normalize_component(snapshot_id, "snapshot_id")
77
+ worktrees_root = _archive_worktrees_root(repo_root)
78
+ if not worktrees_root.exists():
79
+ raise FileNotFoundError("archive root missing")
80
+
81
+ matches: list[tuple[str, Path]] = []
82
+ if worktree_repo_id:
83
+ worktree_repo_id = _normalize_component(worktree_repo_id, "worktree_repo_id")
84
+ candidate = worktrees_root / worktree_repo_id / snapshot_id
85
+ if candidate.exists() and candidate.is_dir():
86
+ matches.append((worktree_repo_id, candidate))
87
+ else:
88
+ for worktree_dir in sorted(worktrees_root.iterdir(), key=lambda p: p.name):
89
+ if not worktree_dir.is_dir():
90
+ continue
91
+ worktree_id = worktree_dir.name
92
+ candidate = worktree_dir / snapshot_id
93
+ if candidate.exists() and candidate.is_dir():
94
+ matches.append((worktree_id, candidate))
95
+
96
+ if not matches:
97
+ raise FileNotFoundError("snapshot not found")
98
+ if len(matches) > 1:
99
+ raise RuntimeError("snapshot id ambiguous")
100
+
101
+ worktree_id, snapshot_root = matches[0]
102
+ resolved_root = snapshot_root.resolve(strict=False)
103
+ archive_root = worktrees_root.resolve(strict=False)
104
+ try:
105
+ resolved_root.relative_to(archive_root)
106
+ except ValueError:
107
+ raise ValueError("invalid snapshot path") from None
108
+ return resolved_root, worktree_id
109
+
110
+
111
+ def _safe_mtime(path: Path) -> Optional[float]:
112
+ try:
113
+ return path.stat().st_mtime
114
+ except OSError:
115
+ return None
116
+
117
+
118
+ def _format_created_at(path: Path) -> Optional[str]:
119
+ try:
120
+ return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat()
121
+ except OSError:
122
+ return None
123
+
124
+
125
+ def _load_meta(meta_path: Path) -> Optional[dict[str, Any]]:
126
+ try:
127
+ if not meta_path.exists():
128
+ return None
129
+ raw = meta_path.read_text(encoding="utf-8")
130
+ data = json.loads(raw)
131
+ if isinstance(data, dict):
132
+ return data
133
+ except Exception as exc:
134
+ logger.debug("Failed to read META.json at %s: %s", meta_path, exc)
135
+ return None
136
+
137
+
138
+ def _snapshot_summary(
139
+ snapshot_root: Path,
140
+ worktree_repo_id: str,
141
+ meta: Optional[dict[str, Any]],
142
+ ) -> ArchiveSnapshotSummary:
143
+ snapshot_id = snapshot_root.name
144
+ created_at: Optional[str] = None
145
+ status: Optional[str] = None
146
+ branch: Optional[str] = None
147
+ head_sha: Optional[str] = None
148
+ note: Optional[str] = None
149
+ summary: Optional[dict[str, Any]] = None
150
+
151
+ if meta:
152
+ created_at = str(meta.get("created_at")) if meta.get("created_at") else None
153
+ status = str(meta.get("status")) if meta.get("status") else None
154
+ branch = str(meta.get("branch")) if meta.get("branch") else None
155
+ head_sha = str(meta.get("head_sha")) if meta.get("head_sha") else None
156
+ note = str(meta.get("note")) if meta.get("note") else None
157
+ if isinstance(meta.get("summary"), dict):
158
+ summary = meta.get("summary")
159
+
160
+ if not created_at:
161
+ created_at = _format_created_at(snapshot_root)
162
+ if not status:
163
+ status = "partial" if meta is None else "unknown"
164
+
165
+ return ArchiveSnapshotSummary(
166
+ snapshot_id=snapshot_id,
167
+ worktree_repo_id=worktree_repo_id,
168
+ created_at=created_at,
169
+ status=status,
170
+ branch=branch,
171
+ head_sha=head_sha,
172
+ note=note,
173
+ summary=summary,
174
+ )
175
+
176
+
177
+ def _iter_snapshots(repo_root: Path) -> list[ArchiveSnapshotSummary]:
178
+ worktrees_root = _archive_worktrees_root(repo_root)
179
+ if not worktrees_root.exists() or not worktrees_root.is_dir():
180
+ return []
181
+ snapshots: list[ArchiveSnapshotSummary] = []
182
+ for worktree_dir in sorted(worktrees_root.iterdir(), key=lambda p: p.name):
183
+ if not worktree_dir.is_dir():
184
+ continue
185
+ worktree_id = worktree_dir.name
186
+ for snapshot_dir in sorted(worktree_dir.iterdir(), key=lambda p: p.name):
187
+ if not snapshot_dir.is_dir():
188
+ continue
189
+ meta = _load_meta(snapshot_dir / "META.json")
190
+ snapshots.append(_snapshot_summary(snapshot_dir, worktree_id, meta))
191
+ snapshots.sort(
192
+ key=lambda item: (item.created_at or "", item.snapshot_id), reverse=True
193
+ )
194
+ return snapshots
195
+
196
+
197
+ def _list_tree(snapshot_root: Path, rel_path: str) -> ArchiveTreeResponse:
198
+ target, rel_posix = _normalize_archive_rel_path(snapshot_root, rel_path)
199
+ if (
200
+ not target.exists()
201
+ ): # codeql[py/path-injection] target normalized to snapshot root
202
+ raise FileNotFoundError("path not found")
203
+ if (
204
+ not target.is_dir()
205
+ ): # codeql[py/path-injection] target normalized to snapshot root
206
+ raise ValueError("path is not a directory")
207
+
208
+ root_real = snapshot_root.resolve(strict=False)
209
+ nodes: list[dict[str, Any]] = []
210
+ for child in sorted(target.iterdir(), key=lambda p: p.name):
211
+ try:
212
+ resolved = child.resolve(strict=False)
213
+ resolved.relative_to(root_real)
214
+ except Exception:
215
+ continue
216
+
217
+ if child.is_dir():
218
+ node_type = "folder"
219
+ size_bytes = None
220
+ else:
221
+ node_type = "file"
222
+ try:
223
+ size_bytes = child.stat().st_size
224
+ except OSError:
225
+ size_bytes = None
226
+
227
+ try:
228
+ node_path = resolved.relative_to(root_real).as_posix()
229
+ except ValueError:
230
+ continue
231
+
232
+ nodes.append(
233
+ {
234
+ "path": node_path,
235
+ "name": child.name,
236
+ "type": node_type,
237
+ "size_bytes": size_bytes,
238
+ "mtime": _safe_mtime(child),
239
+ }
240
+ )
241
+
242
+ return ArchiveTreeResponse(path=rel_posix, nodes=nodes)
243
+
244
+
245
+ def build_archive_routes() -> APIRouter:
246
+ router = APIRouter(prefix="/api/archive", tags=["archive"])
247
+
248
+ @router.get("/snapshots", response_model=ArchiveSnapshotsResponse)
249
+ def list_snapshots(request: Request):
250
+ repo_root = request.app.state.engine.repo_root
251
+ snapshots = _iter_snapshots(repo_root)
252
+ return {"snapshots": snapshots}
253
+
254
+ @router.get(
255
+ "/snapshots/{snapshot_id}", response_model=ArchiveSnapshotDetailResponse
256
+ )
257
+ def get_snapshot(
258
+ request: Request, snapshot_id: str, worktree_repo_id: Optional[str] = None
259
+ ):
260
+ repo_root = request.app.state.engine.repo_root
261
+ try:
262
+ snapshot_root, worktree_id = _resolve_snapshot_root(
263
+ repo_root, snapshot_id, worktree_repo_id
264
+ )
265
+ except ValueError as exc:
266
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
267
+ except FileNotFoundError as exc:
268
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
269
+ except RuntimeError as exc:
270
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
271
+
272
+ meta = _load_meta(snapshot_root / "META.json")
273
+ summary = _snapshot_summary(snapshot_root, worktree_id, meta)
274
+ return {"snapshot": summary, "meta": meta}
275
+
276
+ @router.get("/tree", response_model=ArchiveTreeResponse)
277
+ def list_tree(
278
+ request: Request,
279
+ snapshot_id: str,
280
+ path: str = "",
281
+ worktree_repo_id: Optional[str] = None,
282
+ ):
283
+ repo_root = request.app.state.engine.repo_root
284
+ try:
285
+ snapshot_root, _ = _resolve_snapshot_root(
286
+ repo_root, snapshot_id, worktree_repo_id
287
+ )
288
+ response = _list_tree(snapshot_root, path)
289
+ except ValueError as exc:
290
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
291
+ except FileNotFoundError as exc:
292
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
293
+ except RuntimeError as exc:
294
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
295
+ return response
296
+
297
+ @router.get("/file", response_class=PlainTextResponse)
298
+ def read_file(
299
+ request: Request,
300
+ snapshot_id: str,
301
+ path: str,
302
+ worktree_repo_id: Optional[str] = None,
303
+ ):
304
+ repo_root = request.app.state.engine.repo_root
305
+ try:
306
+ snapshot_root, _ = _resolve_snapshot_root(
307
+ repo_root, snapshot_id, worktree_repo_id
308
+ )
309
+ target, _ = _normalize_archive_rel_path(snapshot_root, path)
310
+ except ValueError as exc:
311
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
312
+ except FileNotFoundError as exc:
313
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
314
+ except RuntimeError as exc:
315
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
316
+
317
+ if not target.exists() or target.is_dir():
318
+ raise HTTPException(status_code=404, detail="file not found")
319
+
320
+ try:
321
+ content = target.read_text(encoding="utf-8", errors="replace")
322
+ except OSError as exc:
323
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
324
+ return PlainTextResponse(content)
325
+
326
+ @router.get("/download")
327
+ def download_file(
328
+ request: Request,
329
+ snapshot_id: str,
330
+ path: str,
331
+ worktree_repo_id: Optional[str] = None,
332
+ ):
333
+ repo_root = request.app.state.engine.repo_root
334
+ try:
335
+ snapshot_root, _ = _resolve_snapshot_root(
336
+ repo_root, snapshot_id, worktree_repo_id
337
+ )
338
+ target, _ = _normalize_archive_rel_path(snapshot_root, path)
339
+ except ValueError as exc:
340
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
341
+ except FileNotFoundError as exc:
342
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
343
+ except RuntimeError as exc:
344
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
345
+
346
+ if not target.exists() or target.is_dir():
347
+ raise HTTPException(status_code=404, detail="file not found")
348
+
349
+ return FileResponse(
350
+ path=target, # codeql[py/path-injection] target validated by normalize helper
351
+ filename=target.name,
352
+ )
353
+
354
+ return router
355
+
356
+
357
+ __all__ = ["build_archive_routes"]