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.
- codex_autorunner/__init__.py +12 -1
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +113 -4
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/runtime.py +59 -18
- codex_autorunner/agents/opencode/supervisor.py +4 -0
- codex_autorunner/agents/registry.py +36 -7
- codex_autorunner/bootstrap.py +226 -4
- codex_autorunner/cli.py +5 -1174
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +20 -0
- codex_autorunner/core/about_car.py +119 -1
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_threads.py +17 -2
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +6 -2
- codex_autorunner/core/config.py +433 -4
- codex_autorunner/core/context_awareness.py +38 -0
- codex_autorunner/core/docs.py +0 -122
- codex_autorunner/core/drafts.py +58 -4
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/filebox.py +265 -0
- codex_autorunner/core/flows/controller.py +96 -2
- codex_autorunner/core/flows/models.py +13 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +134 -0
- codex_autorunner/core/flows/runtime.py +57 -4
- codex_autorunner/core/flows/store.py +142 -7
- codex_autorunner/core/flows/transition.py +27 -15
- codex_autorunner/core/flows/ux_helpers.py +272 -0
- codex_autorunner/core/flows/worker_process.py +32 -6
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +291 -20
- codex_autorunner/core/lifecycle_events.py +253 -0
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/path_utils.py +2 -1
- codex_autorunner/core/pma_audit.py +224 -0
- codex_autorunner/core/pma_context.py +496 -0
- codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
- codex_autorunner/core/pma_lifecycle.py +527 -0
- codex_autorunner/core/pma_queue.py +367 -0
- codex_autorunner/core/pma_safety.py +221 -0
- codex_autorunner/core/pma_state.py +115 -0
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
- codex_autorunner/core/prompt.py +0 -80
- codex_autorunner/core/prompts.py +56 -172
- codex_autorunner/core/redaction.py +0 -4
- codex_autorunner/core/review_context.py +11 -9
- codex_autorunner/core/runner_controller.py +35 -33
- codex_autorunner/core/runner_state.py +147 -0
- codex_autorunner/core/runtime.py +829 -0
- codex_autorunner/core/sqlite_utils.py +13 -4
- codex_autorunner/core/state.py +7 -10
- codex_autorunner/core/state_roots.py +62 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/templates/__init__.py +39 -0
- codex_autorunner/core/templates/git_mirror.py +234 -0
- codex_autorunner/core/templates/provenance.py +56 -0
- codex_autorunner/core/templates/scan_cache.py +120 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +218 -0
- codex_autorunner/core/ticket_manager_cli.py +494 -0
- codex_autorunner/core/time_utils.py +11 -0
- codex_autorunner/core/types.py +18 -0
- codex_autorunner/core/update.py +4 -5
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +125 -15
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
- codex_autorunner/flows/ticket_flow/definition.py +52 -3
- codex_autorunner/integrations/agents/__init__.py +11 -19
- codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +177 -25
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +305 -32
- codex_autorunner/integrations/agents/runner.py +86 -0
- codex_autorunner/integrations/agents/wiring.py +279 -0
- codex_autorunner/integrations/app_server/client.py +7 -60
- codex_autorunner/integrations/app_server/env.py +2 -107
- codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
- codex_autorunner/integrations/telegram/adapter.py +65 -0
- codex_autorunner/integrations/telegram/config.py +46 -0
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/doctor.py +228 -6
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
- codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
- codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +22 -1
- codex_autorunner/integrations/telegram/runtime.py +9 -4
- codex_autorunner/integrations/telegram/service.py +45 -10
- codex_autorunner/integrations/telegram/state.py +38 -0
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
- codex_autorunner/integrations/telegram/transport.py +13 -4
- codex_autorunner/integrations/templates/__init__.py +27 -0
- codex_autorunner/integrations/templates/scan_agent.py +312 -0
- codex_autorunner/routes/__init__.py +37 -76
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +2 -238
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -596
- codex_autorunner/routes/file_chat.py +4 -833
- codex_autorunner/routes/flows.py +4 -977
- codex_autorunner/routes/messages.py +4 -456
- codex_autorunner/routes/repos.py +2 -196
- codex_autorunner/routes/review.py +2 -147
- codex_autorunner/routes/sessions.py +2 -175
- codex_autorunner/routes/settings.py +2 -168
- codex_autorunner/routes/shared.py +2 -275
- codex_autorunner/routes/system.py +4 -193
- codex_autorunner/routes/usage.py +2 -86
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +2 -270
- codex_autorunner/server.py +4 -4
- codex_autorunner/static/agentControls.js +61 -16
- codex_autorunner/static/app.js +126 -14
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +7 -7
- codex_autorunner/static/chatUploads.js +137 -0
- codex_autorunner/static/dashboard.js +224 -171
- codex_autorunner/static/docChatCore.js +185 -13
- codex_autorunner/static/fileChat.js +68 -40
- codex_autorunner/static/fileboxUi.js +159 -0
- codex_autorunner/static/hub.js +114 -131
- codex_autorunner/static/index.html +375 -49
- codex_autorunner/static/messages.js +568 -87
- codex_autorunner/static/notifications.js +255 -0
- codex_autorunner/static/pma.js +1167 -0
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +128 -6
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/streamUtils.js +57 -0
- codex_autorunner/static/styles.css +9798 -6143
- codex_autorunner/static/tabs.js +152 -11
- codex_autorunner/static/templateReposSettings.js +225 -0
- codex_autorunner/static/terminal.js +18 -0
- codex_autorunner/static/ticketChatActions.js +165 -3
- codex_autorunner/static/ticketChatStream.js +17 -119
- codex_autorunner/static/ticketEditor.js +137 -15
- codex_autorunner/static/ticketTemplates.js +798 -0
- codex_autorunner/static/tickets.js +821 -98
- codex_autorunner/static/turnEvents.js +27 -0
- codex_autorunner/static/turnResume.js +33 -0
- codex_autorunner/static/utils.js +39 -0
- codex_autorunner/static/workspace.js +389 -82
- codex_autorunner/static/workspaceFileBrowser.js +15 -13
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +2534 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/cli/pma_cli.py +817 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2223 -0
- codex_autorunner/surfaces/web/hub_jobs.py +192 -0
- codex_autorunner/surfaces/web/middleware.py +587 -0
- codex_autorunner/surfaces/web/pty_session.py +370 -0
- codex_autorunner/surfaces/web/review.py +6 -0
- codex_autorunner/surfaces/web/routes/__init__.py +82 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +284 -0
- codex_autorunner/surfaces/web/routes/app_server.py +132 -0
- codex_autorunner/surfaces/web/routes/archive.py +357 -0
- codex_autorunner/surfaces/web/routes/base.py +615 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
- codex_autorunner/surfaces/web/routes/filebox.py +227 -0
- codex_autorunner/surfaces/web/routes/flows.py +1354 -0
- codex_autorunner/surfaces/web/routes/messages.py +490 -0
- codex_autorunner/surfaces/web/routes/pma.py +1652 -0
- codex_autorunner/surfaces/web/routes/repos.py +197 -0
- codex_autorunner/surfaces/web/routes/review.py +148 -0
- codex_autorunner/surfaces/web/routes/sessions.py +176 -0
- codex_autorunner/surfaces/web/routes/settings.py +169 -0
- codex_autorunner/surfaces/web/routes/shared.py +277 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/templates.py +634 -0
- codex_autorunner/surfaces/web/routes/usage.py +89 -0
- codex_autorunner/surfaces/web/routes/voice.py +120 -0
- codex_autorunner/surfaces/web/routes/workspace.py +271 -0
- codex_autorunner/surfaces/web/runner_manager.py +25 -0
- codex_autorunner/surfaces/web/schemas.py +469 -0
- codex_autorunner/surfaces/web/static_assets.py +490 -0
- codex_autorunner/surfaces/web/static_refresh.py +86 -0
- codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
- codex_autorunner/tickets/__init__.py +8 -1
- codex_autorunner/tickets/agent_pool.py +53 -4
- codex_autorunner/tickets/files.py +37 -16
- codex_autorunner/tickets/lint.py +50 -0
- codex_autorunner/tickets/models.py +6 -1
- codex_autorunner/tickets/outbox.py +50 -2
- codex_autorunner/tickets/runner.py +396 -57
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1949
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -586
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -376
- codex_autorunner/web/static_assets.py +4 -441
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/paths.py +49 -33
- codex_autorunner-1.2.0.dist-info/METADATA +150 -0
- codex_autorunner-1.2.0.dist-info/RECORD +339 -0
- codex_autorunner/core/adapter_utils.py +0 -21
- codex_autorunner/core/engine.py +0 -2653
- codex_autorunner/core/static_assets.py +0 -55
- codex_autorunner-1.0.0.dist-info/METADATA +0 -246
- codex_autorunner-1.0.0.dist-info/RECORD +0 -251
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {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"]
|