codex-autorunner 0.1.2__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 (276) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/__main__.py +4 -0
  3. codex_autorunner/agents/codex/harness.py +1 -1
  4. codex_autorunner/agents/opencode/client.py +68 -35
  5. codex_autorunner/agents/opencode/constants.py +3 -0
  6. codex_autorunner/agents/opencode/harness.py +6 -1
  7. codex_autorunner/agents/opencode/logging.py +21 -5
  8. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  9. codex_autorunner/agents/opencode/runtime.py +176 -47
  10. codex_autorunner/agents/opencode/supervisor.py +36 -48
  11. codex_autorunner/agents/registry.py +155 -8
  12. codex_autorunner/api.py +25 -0
  13. codex_autorunner/bootstrap.py +22 -37
  14. codex_autorunner/cli.py +5 -1156
  15. codex_autorunner/codex_cli.py +20 -84
  16. codex_autorunner/core/__init__.py +4 -0
  17. codex_autorunner/core/about_car.py +49 -32
  18. codex_autorunner/core/adapter_utils.py +21 -0
  19. codex_autorunner/core/app_server_ids.py +59 -0
  20. codex_autorunner/core/app_server_logging.py +7 -3
  21. codex_autorunner/core/app_server_prompts.py +27 -260
  22. codex_autorunner/core/app_server_threads.py +26 -28
  23. codex_autorunner/core/app_server_utils.py +165 -0
  24. codex_autorunner/core/archive.py +349 -0
  25. codex_autorunner/core/codex_runner.py +12 -2
  26. codex_autorunner/core/config.py +587 -103
  27. codex_autorunner/core/docs.py +10 -2
  28. codex_autorunner/core/drafts.py +136 -0
  29. codex_autorunner/core/engine.py +1531 -866
  30. codex_autorunner/core/exceptions.py +4 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +202 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +88 -0
  35. codex_autorunner/core/flows/reasons.py +52 -0
  36. codex_autorunner/core/flows/reconciler.py +131 -0
  37. codex_autorunner/core/flows/runtime.py +382 -0
  38. codex_autorunner/core/flows/store.py +568 -0
  39. codex_autorunner/core/flows/transition.py +138 -0
  40. codex_autorunner/core/flows/ux_helpers.py +257 -0
  41. codex_autorunner/core/flows/worker_process.py +242 -0
  42. codex_autorunner/core/git_utils.py +62 -0
  43. codex_autorunner/core/hub.py +136 -16
  44. codex_autorunner/core/locks.py +4 -0
  45. codex_autorunner/core/notifications.py +14 -2
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/core/ports/agent_backend.py +150 -0
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/core/ports/run_event.py +91 -0
  50. codex_autorunner/core/prompt.py +15 -7
  51. codex_autorunner/core/redaction.py +29 -0
  52. codex_autorunner/core/review_context.py +5 -8
  53. codex_autorunner/core/run_index.py +6 -0
  54. codex_autorunner/core/runner_process.py +5 -2
  55. codex_autorunner/core/state.py +0 -88
  56. codex_autorunner/core/state_roots.py +57 -0
  57. codex_autorunner/core/supervisor_protocol.py +15 -0
  58. codex_autorunner/core/supervisor_utils.py +67 -0
  59. codex_autorunner/core/text_delta_coalescer.py +54 -0
  60. codex_autorunner/core/ticket_linter_cli.py +201 -0
  61. codex_autorunner/core/ticket_manager_cli.py +432 -0
  62. codex_autorunner/core/update.py +24 -16
  63. codex_autorunner/core/update_paths.py +28 -0
  64. codex_autorunner/core/update_runner.py +2 -0
  65. codex_autorunner/core/usage.py +164 -12
  66. codex_autorunner/core/utils.py +120 -11
  67. codex_autorunner/discovery.py +2 -4
  68. codex_autorunner/flows/review/__init__.py +17 -0
  69. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  70. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  71. codex_autorunner/flows/ticket_flow/definition.py +98 -0
  72. codex_autorunner/integrations/agents/__init__.py +17 -0
  73. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  74. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  75. codex_autorunner/integrations/agents/codex_backend.py +448 -0
  76. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  77. codex_autorunner/integrations/agents/opencode_backend.py +598 -0
  78. codex_autorunner/integrations/agents/runner.py +91 -0
  79. codex_autorunner/integrations/agents/wiring.py +271 -0
  80. codex_autorunner/integrations/app_server/client.py +583 -152
  81. codex_autorunner/integrations/app_server/env.py +2 -107
  82. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  83. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  84. codex_autorunner/integrations/telegram/adapter.py +204 -165
  85. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  86. codex_autorunner/integrations/telegram/config.py +221 -0
  87. codex_autorunner/integrations/telegram/constants.py +17 -2
  88. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  89. codex_autorunner/integrations/telegram/doctor.py +47 -0
  90. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
  91. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  92. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  93. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  94. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
  95. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  96. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  97. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  98. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
  99. codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
  100. codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
  101. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  102. codex_autorunner/integrations/telegram/helpers.py +111 -16
  103. codex_autorunner/integrations/telegram/outbox.py +208 -37
  104. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  105. codex_autorunner/integrations/telegram/service.py +221 -42
  106. codex_autorunner/integrations/telegram/state.py +100 -2
  107. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
  108. codex_autorunner/integrations/telegram/transport.py +39 -4
  109. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  110. codex_autorunner/manifest.py +2 -0
  111. codex_autorunner/plugin_api.py +22 -0
  112. codex_autorunner/routes/__init__.py +37 -67
  113. codex_autorunner/routes/agents.py +2 -137
  114. codex_autorunner/routes/analytics.py +3 -0
  115. codex_autorunner/routes/app_server.py +2 -131
  116. codex_autorunner/routes/base.py +2 -624
  117. codex_autorunner/routes/file_chat.py +7 -0
  118. codex_autorunner/routes/flows.py +7 -0
  119. codex_autorunner/routes/messages.py +7 -0
  120. codex_autorunner/routes/repos.py +2 -196
  121. codex_autorunner/routes/review.py +2 -147
  122. codex_autorunner/routes/sessions.py +2 -175
  123. codex_autorunner/routes/settings.py +2 -168
  124. codex_autorunner/routes/shared.py +2 -275
  125. codex_autorunner/routes/system.py +4 -188
  126. codex_autorunner/routes/usage.py +3 -0
  127. codex_autorunner/routes/voice.py +2 -119
  128. codex_autorunner/routes/workspace.py +3 -0
  129. codex_autorunner/server.py +3 -2
  130. codex_autorunner/static/agentControls.js +41 -11
  131. codex_autorunner/static/agentEvents.js +248 -0
  132. codex_autorunner/static/app.js +35 -24
  133. codex_autorunner/static/archive.js +826 -0
  134. codex_autorunner/static/archiveApi.js +37 -0
  135. codex_autorunner/static/autoRefresh.js +36 -8
  136. codex_autorunner/static/bootstrap.js +1 -0
  137. codex_autorunner/static/bus.js +1 -0
  138. codex_autorunner/static/cache.js +1 -0
  139. codex_autorunner/static/constants.js +20 -4
  140. codex_autorunner/static/dashboard.js +344 -325
  141. codex_autorunner/static/diffRenderer.js +37 -0
  142. codex_autorunner/static/docChatCore.js +324 -0
  143. codex_autorunner/static/docChatStorage.js +65 -0
  144. codex_autorunner/static/docChatVoice.js +65 -0
  145. codex_autorunner/static/docEditor.js +133 -0
  146. codex_autorunner/static/env.js +1 -0
  147. codex_autorunner/static/eventSummarizer.js +166 -0
  148. codex_autorunner/static/fileChat.js +182 -0
  149. codex_autorunner/static/health.js +155 -0
  150. codex_autorunner/static/hub.js +126 -185
  151. codex_autorunner/static/index.html +839 -863
  152. codex_autorunner/static/liveUpdates.js +1 -0
  153. codex_autorunner/static/loader.js +1 -0
  154. codex_autorunner/static/messages.js +873 -0
  155. codex_autorunner/static/mobileCompact.js +2 -1
  156. codex_autorunner/static/preserve.js +17 -0
  157. codex_autorunner/static/settings.js +149 -217
  158. codex_autorunner/static/smartRefresh.js +52 -0
  159. codex_autorunner/static/styles.css +8850 -3876
  160. codex_autorunner/static/tabs.js +175 -11
  161. codex_autorunner/static/terminal.js +32 -0
  162. codex_autorunner/static/terminalManager.js +34 -59
  163. codex_autorunner/static/ticketChatActions.js +333 -0
  164. codex_autorunner/static/ticketChatEvents.js +16 -0
  165. codex_autorunner/static/ticketChatStorage.js +16 -0
  166. codex_autorunner/static/ticketChatStream.js +264 -0
  167. codex_autorunner/static/ticketEditor.js +844 -0
  168. codex_autorunner/static/ticketVoice.js +9 -0
  169. codex_autorunner/static/tickets.js +1988 -0
  170. codex_autorunner/static/utils.js +43 -3
  171. codex_autorunner/static/voice.js +1 -0
  172. codex_autorunner/static/workspace.js +765 -0
  173. codex_autorunner/static/workspaceApi.js +53 -0
  174. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  175. codex_autorunner/surfaces/__init__.py +5 -0
  176. codex_autorunner/surfaces/cli/__init__.py +6 -0
  177. codex_autorunner/surfaces/cli/cli.py +1224 -0
  178. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  179. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  180. codex_autorunner/surfaces/web/__init__.py +1 -0
  181. codex_autorunner/surfaces/web/app.py +2019 -0
  182. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  183. codex_autorunner/surfaces/web/middleware.py +587 -0
  184. codex_autorunner/surfaces/web/pty_session.py +370 -0
  185. codex_autorunner/surfaces/web/review.py +6 -0
  186. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  187. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  188. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  189. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  190. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  191. codex_autorunner/surfaces/web/routes/base.py +615 -0
  192. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  193. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  194. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  195. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  196. codex_autorunner/surfaces/web/routes/review.py +148 -0
  197. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  198. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  199. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  200. codex_autorunner/surfaces/web/routes/system.py +196 -0
  201. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  202. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  203. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  204. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  205. codex_autorunner/surfaces/web/schemas.py +417 -0
  206. codex_autorunner/surfaces/web/static_assets.py +490 -0
  207. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  208. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  209. codex_autorunner/tickets/__init__.py +27 -0
  210. codex_autorunner/tickets/agent_pool.py +399 -0
  211. codex_autorunner/tickets/files.py +89 -0
  212. codex_autorunner/tickets/frontmatter.py +55 -0
  213. codex_autorunner/tickets/lint.py +102 -0
  214. codex_autorunner/tickets/models.py +97 -0
  215. codex_autorunner/tickets/outbox.py +244 -0
  216. codex_autorunner/tickets/replies.py +179 -0
  217. codex_autorunner/tickets/runner.py +881 -0
  218. codex_autorunner/tickets/spec_ingest.py +77 -0
  219. codex_autorunner/web/__init__.py +5 -1
  220. codex_autorunner/web/app.py +2 -1771
  221. codex_autorunner/web/hub_jobs.py +2 -191
  222. codex_autorunner/web/middleware.py +2 -587
  223. codex_autorunner/web/pty_session.py +2 -369
  224. codex_autorunner/web/runner_manager.py +2 -24
  225. codex_autorunner/web/schemas.py +2 -396
  226. codex_autorunner/web/static_assets.py +4 -484
  227. codex_autorunner/web/static_refresh.py +2 -85
  228. codex_autorunner/web/terminal_sessions.py +2 -77
  229. codex_autorunner/workspace/__init__.py +40 -0
  230. codex_autorunner/workspace/paths.py +335 -0
  231. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  232. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  233. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
  234. codex_autorunner/agents/execution/policy.py +0 -292
  235. codex_autorunner/agents/factory.py +0 -52
  236. codex_autorunner/agents/orchestrator.py +0 -358
  237. codex_autorunner/core/doc_chat.py +0 -1446
  238. codex_autorunner/core/snapshot.py +0 -580
  239. codex_autorunner/integrations/github/chatops.py +0 -268
  240. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  241. codex_autorunner/routes/docs.py +0 -381
  242. codex_autorunner/routes/github.py +0 -327
  243. codex_autorunner/routes/runs.py +0 -250
  244. codex_autorunner/spec_ingest.py +0 -812
  245. codex_autorunner/static/docChatActions.js +0 -287
  246. codex_autorunner/static/docChatEvents.js +0 -300
  247. codex_autorunner/static/docChatRender.js +0 -205
  248. codex_autorunner/static/docChatStream.js +0 -361
  249. codex_autorunner/static/docs.js +0 -20
  250. codex_autorunner/static/docsClipboard.js +0 -69
  251. codex_autorunner/static/docsCrud.js +0 -257
  252. codex_autorunner/static/docsDocUpdates.js +0 -62
  253. codex_autorunner/static/docsDrafts.js +0 -16
  254. codex_autorunner/static/docsElements.js +0 -69
  255. codex_autorunner/static/docsInit.js +0 -285
  256. codex_autorunner/static/docsParse.js +0 -160
  257. codex_autorunner/static/docsSnapshot.js +0 -87
  258. codex_autorunner/static/docsSpecIngest.js +0 -263
  259. codex_autorunner/static/docsState.js +0 -127
  260. codex_autorunner/static/docsThreadRegistry.js +0 -44
  261. codex_autorunner/static/docsUi.js +0 -153
  262. codex_autorunner/static/docsVoice.js +0 -56
  263. codex_autorunner/static/github.js +0 -504
  264. codex_autorunner/static/logs.js +0 -678
  265. codex_autorunner/static/review.js +0 -157
  266. codex_autorunner/static/runs.js +0 -418
  267. codex_autorunner/static/snapshot.js +0 -124
  268. codex_autorunner/static/state.js +0 -94
  269. codex_autorunner/static/todoPreview.js +0 -27
  270. codex_autorunner/workspace.py +0 -16
  271. codex_autorunner-0.1.2.dist-info/METADATA +0 -249
  272. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  273. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  274. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  275. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  276. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.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"]