codex-autorunner 1.2.0__py3-none-any.whl → 1.3.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/bootstrap.py +26 -5
- codex_autorunner/core/about_car.py +12 -12
- codex_autorunner/core/config.py +178 -61
- codex_autorunner/core/context_awareness.py +1 -0
- codex_autorunner/core/filesystem.py +24 -0
- codex_autorunner/core/flows/controller.py +50 -12
- codex_autorunner/core/flows/runtime.py +8 -3
- codex_autorunner/core/hub.py +293 -16
- codex_autorunner/core/lifecycle_events.py +44 -5
- codex_autorunner/core/pma_context.py +188 -1
- codex_autorunner/core/pma_delivery.py +81 -0
- codex_autorunner/core/pma_dispatches.py +224 -0
- codex_autorunner/core/pma_lane_worker.py +122 -0
- codex_autorunner/core/pma_queue.py +167 -18
- codex_autorunner/core/pma_reactive.py +91 -0
- codex_autorunner/core/pma_safety.py +58 -0
- codex_autorunner/core/pma_sink.py +104 -0
- codex_autorunner/core/pma_transcripts.py +183 -0
- codex_autorunner/core/safe_paths.py +117 -0
- codex_autorunner/housekeeping.py +77 -23
- codex_autorunner/integrations/agents/codex_backend.py +18 -12
- codex_autorunner/integrations/agents/wiring.py +2 -0
- codex_autorunner/integrations/app_server/client.py +31 -0
- codex_autorunner/integrations/app_server/supervisor.py +3 -0
- codex_autorunner/integrations/telegram/adapter.py +1 -1
- codex_autorunner/integrations/telegram/config.py +1 -1
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +16 -15
- codex_autorunner/integrations/telegram/handlers/commands/files.py +5 -8
- codex_autorunner/integrations/telegram/handlers/commands/github.py +10 -6
- codex_autorunner/integrations/telegram/handlers/commands/shared.py +9 -8
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +85 -2
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +29 -8
- codex_autorunner/integrations/telegram/handlers/messages.py +8 -2
- codex_autorunner/integrations/telegram/helpers.py +30 -2
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +54 -3
- codex_autorunner/static/archive.js +274 -81
- codex_autorunner/static/archiveApi.js +21 -0
- codex_autorunner/static/constants.js +1 -1
- codex_autorunner/static/docChatCore.js +2 -0
- codex_autorunner/static/hub.js +59 -0
- codex_autorunner/static/index.html +70 -54
- codex_autorunner/static/notificationBell.js +173 -0
- codex_autorunner/static/notifications.js +187 -36
- codex_autorunner/static/pma.js +96 -35
- codex_autorunner/static/styles.css +431 -4
- codex_autorunner/static/terminalManager.js +22 -3
- codex_autorunner/static/utils.js +5 -1
- codex_autorunner/surfaces/cli/cli.py +206 -129
- codex_autorunner/surfaces/cli/template_repos.py +157 -0
- codex_autorunner/surfaces/web/app.py +193 -5
- codex_autorunner/surfaces/web/routes/archive.py +197 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +115 -87
- codex_autorunner/surfaces/web/routes/flows.py +125 -67
- codex_autorunner/surfaces/web/routes/pma.py +638 -57
- codex_autorunner/surfaces/web/schemas.py +11 -0
- codex_autorunner/tickets/agent_pool.py +6 -1
- codex_autorunner/tickets/outbox.py +27 -14
- codex_autorunner/tickets/replies.py +4 -10
- codex_autorunner/tickets/runner.py +1 -0
- codex_autorunner/workspace/paths.py +8 -3
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/METADATA +1 -1
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/RECORD +67 -57
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/top_level.txt +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import json
|
|
2
3
|
import logging
|
|
3
4
|
import os
|
|
4
5
|
import shlex
|
|
@@ -6,8 +7,9 @@ import sys
|
|
|
6
7
|
import threading
|
|
7
8
|
from contextlib import ExitStack, asynccontextmanager
|
|
8
9
|
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
9
11
|
from pathlib import Path
|
|
10
|
-
from typing import Mapping, Optional
|
|
12
|
+
from typing import Any, Mapping, Optional
|
|
11
13
|
|
|
12
14
|
from fastapi import FastAPI, HTTPException
|
|
13
15
|
from fastapi.responses import HTMLResponse
|
|
@@ -53,6 +55,7 @@ from ...core.usage import (
|
|
|
53
55
|
parse_iso_datetime,
|
|
54
56
|
)
|
|
55
57
|
from ...core.utils import (
|
|
58
|
+
atomic_write,
|
|
56
59
|
build_opencode_supervisor,
|
|
57
60
|
reset_repo_root_context,
|
|
58
61
|
set_repo_root_context,
|
|
@@ -71,6 +74,7 @@ from ...manifest import load_manifest
|
|
|
71
74
|
from ...tickets.files import list_ticket_paths, safe_relpath, ticket_is_done
|
|
72
75
|
from ...tickets.models import Dispatch
|
|
73
76
|
from ...tickets.outbox import parse_dispatch, resolve_outbox_paths
|
|
77
|
+
from ...tickets.replies import resolve_reply_paths
|
|
74
78
|
from ...voice import VoiceConfig, VoiceService
|
|
75
79
|
from .hub_jobs import HubJobManager
|
|
76
80
|
from .middleware import (
|
|
@@ -158,6 +162,86 @@ class ServerOverrides:
|
|
|
158
162
|
auth_token_env: Optional[str] = None
|
|
159
163
|
|
|
160
164
|
|
|
165
|
+
_HUB_INBOX_DISMISSALS_FILENAME = "hub_inbox_dismissals.json"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _hub_inbox_dismissals_path(repo_root: Path) -> Path:
|
|
169
|
+
return repo_root / ".codex-autorunner" / _HUB_INBOX_DISMISSALS_FILENAME
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _dismissal_key(run_id: str, seq: int) -> str:
|
|
173
|
+
return f"{run_id}:{seq}"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _load_hub_inbox_dismissals(repo_root: Path) -> dict[str, dict[str, Any]]:
|
|
177
|
+
path = _hub_inbox_dismissals_path(repo_root)
|
|
178
|
+
if not path.exists():
|
|
179
|
+
return {}
|
|
180
|
+
try:
|
|
181
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
182
|
+
except Exception:
|
|
183
|
+
return {}
|
|
184
|
+
if not isinstance(payload, dict):
|
|
185
|
+
return {}
|
|
186
|
+
items = payload.get("items")
|
|
187
|
+
if not isinstance(items, dict):
|
|
188
|
+
return {}
|
|
189
|
+
out: dict[str, dict[str, Any]] = {}
|
|
190
|
+
for key, value in items.items():
|
|
191
|
+
if not isinstance(key, str) or not isinstance(value, dict):
|
|
192
|
+
continue
|
|
193
|
+
out[key] = dict(value)
|
|
194
|
+
return out
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _save_hub_inbox_dismissals(
|
|
198
|
+
repo_root: Path, items: dict[str, dict[str, Any]]
|
|
199
|
+
) -> None:
|
|
200
|
+
path = _hub_inbox_dismissals_path(repo_root)
|
|
201
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
payload = {"version": 1, "items": items}
|
|
203
|
+
atomic_write(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _resolve_workspace_and_runs(
|
|
207
|
+
record_input: dict[str, Any], repo_root: Path
|
|
208
|
+
) -> tuple[Path, Path]:
|
|
209
|
+
workspace_raw = record_input.get("workspace_root")
|
|
210
|
+
workspace_root = Path(workspace_raw) if workspace_raw else repo_root
|
|
211
|
+
if not workspace_root.is_absolute():
|
|
212
|
+
workspace_root = (repo_root / workspace_root).resolve()
|
|
213
|
+
else:
|
|
214
|
+
workspace_root = workspace_root.resolve()
|
|
215
|
+
runs_raw = record_input.get("runs_dir") or ".codex-autorunner/runs"
|
|
216
|
+
runs_dir = Path(runs_raw)
|
|
217
|
+
if not runs_dir.is_absolute():
|
|
218
|
+
runs_dir = (workspace_root / runs_dir).resolve()
|
|
219
|
+
return workspace_root, runs_dir
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _latest_reply_history_seq(
|
|
223
|
+
repo_root: Path, run_id: str, record_input: dict[str, Any]
|
|
224
|
+
) -> int:
|
|
225
|
+
workspace_root, runs_dir = _resolve_workspace_and_runs(record_input, repo_root)
|
|
226
|
+
reply_paths = resolve_reply_paths(
|
|
227
|
+
workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
|
|
228
|
+
)
|
|
229
|
+
history_dir = reply_paths.reply_history_dir
|
|
230
|
+
if not history_dir.exists() or not history_dir.is_dir():
|
|
231
|
+
return 0
|
|
232
|
+
latest = 0
|
|
233
|
+
try:
|
|
234
|
+
for child in history_dir.iterdir():
|
|
235
|
+
if not child.is_dir():
|
|
236
|
+
continue
|
|
237
|
+
name = child.name
|
|
238
|
+
if len(name) == 4 and name.isdigit():
|
|
239
|
+
latest = max(latest, int(name))
|
|
240
|
+
except OSError:
|
|
241
|
+
return latest
|
|
242
|
+
return latest
|
|
243
|
+
|
|
244
|
+
|
|
161
245
|
def _app_server_prune_interval(idle_ttl_seconds: Optional[int]) -> Optional[float]:
|
|
162
246
|
if not idle_ttl_seconds or idle_ttl_seconds <= 0:
|
|
163
247
|
return None
|
|
@@ -294,6 +378,7 @@ def _build_app_server_supervisor(
|
|
|
294
378
|
restart_backoff_initial_seconds=config.client.restart_backoff_initial_seconds,
|
|
295
379
|
restart_backoff_max_seconds=config.client.restart_backoff_max_seconds,
|
|
296
380
|
restart_backoff_jitter_ratio=config.client.restart_backoff_jitter_ratio,
|
|
381
|
+
output_policy=config.output.policy,
|
|
297
382
|
notification_handler=notification_handler,
|
|
298
383
|
approval_handler=approval_handler,
|
|
299
384
|
)
|
|
@@ -1154,7 +1239,14 @@ def create_hub_app(
|
|
|
1154
1239
|
raw_config = getattr(context.config, "raw", {})
|
|
1155
1240
|
pma_config = raw_config.get("pma", {}) if isinstance(raw_config, dict) else {}
|
|
1156
1241
|
if isinstance(pma_config, dict) and pma_config.get("enabled"):
|
|
1157
|
-
|
|
1242
|
+
pma_router = build_pma_routes()
|
|
1243
|
+
app.include_router(pma_router)
|
|
1244
|
+
app.state.pma_lane_worker_start = getattr(
|
|
1245
|
+
pma_router, "_pma_start_lane_worker", None
|
|
1246
|
+
)
|
|
1247
|
+
app.state.pma_lane_worker_stop = getattr(
|
|
1248
|
+
pma_router, "_pma_stop_lane_worker", None
|
|
1249
|
+
)
|
|
1158
1250
|
app.include_router(build_hub_filebox_routes())
|
|
1159
1251
|
mounted_repos: set[str] = set()
|
|
1160
1252
|
mount_errors: dict[str, str] = {}
|
|
@@ -1496,6 +1588,19 @@ def create_hub_app(
|
|
|
1496
1588
|
)
|
|
1497
1589
|
|
|
1498
1590
|
asyncio.create_task(_opencode_prune_loop())
|
|
1591
|
+
pma_cfg = getattr(app.state.config, "pma", None)
|
|
1592
|
+
if pma_cfg is not None and pma_cfg.enabled:
|
|
1593
|
+
starter = getattr(app.state, "pma_lane_worker_start", None)
|
|
1594
|
+
if starter is not None:
|
|
1595
|
+
try:
|
|
1596
|
+
await starter(app, "pma:default")
|
|
1597
|
+
except Exception as exc:
|
|
1598
|
+
safe_log(
|
|
1599
|
+
app.state.logger,
|
|
1600
|
+
logging.WARNING,
|
|
1601
|
+
"PMA lane worker startup failed",
|
|
1602
|
+
exc,
|
|
1603
|
+
)
|
|
1499
1604
|
mount_lock = await _get_mount_lock()
|
|
1500
1605
|
async with mount_lock:
|
|
1501
1606
|
for prefix in list(mount_order):
|
|
@@ -1536,6 +1641,17 @@ def create_hub_app(
|
|
|
1536
1641
|
static_context = getattr(app.state, "static_assets_context", None)
|
|
1537
1642
|
if static_context is not None:
|
|
1538
1643
|
static_context.close()
|
|
1644
|
+
stopper = getattr(app.state, "pma_lane_worker_stop", None)
|
|
1645
|
+
if stopper is not None:
|
|
1646
|
+
try:
|
|
1647
|
+
await stopper(app, "pma:default")
|
|
1648
|
+
except Exception as exc:
|
|
1649
|
+
safe_log(
|
|
1650
|
+
app.state.logger,
|
|
1651
|
+
logging.WARNING,
|
|
1652
|
+
"PMA lane worker shutdown failed",
|
|
1653
|
+
exc,
|
|
1654
|
+
)
|
|
1539
1655
|
|
|
1540
1656
|
app.router.lifespan_context = lifespan
|
|
1541
1657
|
|
|
@@ -1616,6 +1732,18 @@ def create_hub_app(
|
|
|
1616
1732
|
**series,
|
|
1617
1733
|
}
|
|
1618
1734
|
|
|
1735
|
+
hub_dismissal_locks: dict[str, asyncio.Lock] = {}
|
|
1736
|
+
hub_dismissal_locks_guard = asyncio.Lock()
|
|
1737
|
+
|
|
1738
|
+
async def _repo_dismissal_lock(repo_root: Path) -> asyncio.Lock:
|
|
1739
|
+
key = str(repo_root.resolve())
|
|
1740
|
+
async with hub_dismissal_locks_guard:
|
|
1741
|
+
lock = hub_dismissal_locks.get(key)
|
|
1742
|
+
if lock is None:
|
|
1743
|
+
lock = asyncio.Lock()
|
|
1744
|
+
hub_dismissal_locks[key] = lock
|
|
1745
|
+
return lock
|
|
1746
|
+
|
|
1619
1747
|
@app.get("/hub/messages")
|
|
1620
1748
|
async def hub_messages(limit: int = 100):
|
|
1621
1749
|
"""Return paused ticket_flow dispatches across all repos.
|
|
@@ -1746,6 +1874,7 @@ def create_hub_app(
|
|
|
1746
1874
|
for snap in snapshots:
|
|
1747
1875
|
if not (snap.initialized and snap.exists_on_disk):
|
|
1748
1876
|
continue
|
|
1877
|
+
dismissals = _load_hub_inbox_dismissals(snap.path)
|
|
1749
1878
|
repo_root = snap.path
|
|
1750
1879
|
db_path = repo_root / ".codex-autorunner" / "flows.db"
|
|
1751
1880
|
if not db_path.exists():
|
|
@@ -1761,11 +1890,24 @@ def create_hub_app(
|
|
|
1761
1890
|
if not paused:
|
|
1762
1891
|
continue
|
|
1763
1892
|
for record in paused:
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
)
|
|
1893
|
+
record_input = dict(record.input_data or {})
|
|
1894
|
+
latest = _latest_dispatch(repo_root, str(record.id), record_input)
|
|
1767
1895
|
if not latest or not latest.get("dispatch"):
|
|
1768
1896
|
continue
|
|
1897
|
+
seq = int(latest.get("seq") or 0)
|
|
1898
|
+
if seq <= 0:
|
|
1899
|
+
continue
|
|
1900
|
+
if _dismissal_key(str(record.id), seq) in dismissals:
|
|
1901
|
+
continue
|
|
1902
|
+
# Reconcile stale inbox items: if reply history already
|
|
1903
|
+
# reached this dispatch seq, treat it as resolved.
|
|
1904
|
+
if (
|
|
1905
|
+
_latest_reply_history_seq(
|
|
1906
|
+
repo_root, str(record.id), record_input
|
|
1907
|
+
)
|
|
1908
|
+
>= seq
|
|
1909
|
+
):
|
|
1910
|
+
continue
|
|
1769
1911
|
messages.append(
|
|
1770
1912
|
{
|
|
1771
1913
|
"repo_id": snap.id,
|
|
@@ -1788,6 +1930,52 @@ def create_hub_app(
|
|
|
1788
1930
|
items = await asyncio.to_thread(_gather)
|
|
1789
1931
|
return {"items": items}
|
|
1790
1932
|
|
|
1933
|
+
@app.post("/hub/messages/dismiss")
|
|
1934
|
+
async def dismiss_hub_message(payload: dict[str, Any]):
|
|
1935
|
+
repo_id = str(payload.get("repo_id") or "").strip()
|
|
1936
|
+
run_id = str(payload.get("run_id") or "").strip()
|
|
1937
|
+
seq_raw = payload.get("seq")
|
|
1938
|
+
reason_raw = payload.get("reason")
|
|
1939
|
+
reason = str(reason_raw).strip() if isinstance(reason_raw, str) else ""
|
|
1940
|
+
if not repo_id:
|
|
1941
|
+
raise HTTPException(status_code=400, detail="Missing repo_id")
|
|
1942
|
+
if not run_id:
|
|
1943
|
+
raise HTTPException(status_code=400, detail="Missing run_id")
|
|
1944
|
+
try:
|
|
1945
|
+
seq = int(seq_raw)
|
|
1946
|
+
except (TypeError, ValueError):
|
|
1947
|
+
raise HTTPException(status_code=400, detail="Invalid seq") from None
|
|
1948
|
+
if seq <= 0:
|
|
1949
|
+
raise HTTPException(status_code=400, detail="Invalid seq")
|
|
1950
|
+
|
|
1951
|
+
snapshots = await asyncio.to_thread(context.supervisor.list_repos)
|
|
1952
|
+
snapshot = next((s for s in snapshots if s.id == repo_id), None)
|
|
1953
|
+
if snapshot is None or not snapshot.exists_on_disk:
|
|
1954
|
+
raise HTTPException(status_code=404, detail="Repo not found")
|
|
1955
|
+
|
|
1956
|
+
repo_lock = await _repo_dismissal_lock(snapshot.path)
|
|
1957
|
+
async with repo_lock:
|
|
1958
|
+
dismissed_at = datetime.now(timezone.utc).isoformat()
|
|
1959
|
+
items = _load_hub_inbox_dismissals(snapshot.path)
|
|
1960
|
+
items[_dismissal_key(run_id, seq)] = {
|
|
1961
|
+
"repo_id": repo_id,
|
|
1962
|
+
"run_id": run_id,
|
|
1963
|
+
"seq": seq,
|
|
1964
|
+
"reason": reason or None,
|
|
1965
|
+
"dismissed_at": dismissed_at,
|
|
1966
|
+
}
|
|
1967
|
+
_save_hub_inbox_dismissals(snapshot.path, items)
|
|
1968
|
+
return {
|
|
1969
|
+
"status": "ok",
|
|
1970
|
+
"dismissed": {
|
|
1971
|
+
"repo_id": repo_id,
|
|
1972
|
+
"run_id": run_id,
|
|
1973
|
+
"seq": seq,
|
|
1974
|
+
"reason": reason or None,
|
|
1975
|
+
"dismissed_at": dismissed_at,
|
|
1976
|
+
},
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1791
1979
|
@app.get("/hub/repos")
|
|
1792
1980
|
async def list_repos():
|
|
1793
1981
|
safe_log(app.state.logger, logging.INFO, "Hub list_repos")
|
|
@@ -17,6 +17,8 @@ from ..schemas import (
|
|
|
17
17
|
ArchiveSnapshotsResponse,
|
|
18
18
|
ArchiveSnapshotSummary,
|
|
19
19
|
ArchiveTreeResponse,
|
|
20
|
+
LocalRunArchivesResponse,
|
|
21
|
+
LocalRunArchiveSummary,
|
|
20
22
|
)
|
|
21
23
|
|
|
22
24
|
logger = logging.getLogger("codex_autorunner.routes.archive")
|
|
@@ -28,6 +30,10 @@ def _archive_worktrees_root(repo_root: Path) -> Path:
|
|
|
28
30
|
return repo_root / ".codex-autorunner" / "archive" / "worktrees"
|
|
29
31
|
|
|
30
32
|
|
|
33
|
+
def _local_flows_root(repo_root: Path) -> Path:
|
|
34
|
+
return repo_root / ".codex-autorunner" / "flows"
|
|
35
|
+
|
|
36
|
+
|
|
31
37
|
def _normalize_component(value: str, label: str) -> str:
|
|
32
38
|
cleaned = (value or "").strip()
|
|
33
39
|
if not cleaned:
|
|
@@ -68,6 +74,18 @@ def _normalize_archive_rel_path(base: Path, rel_path: str) -> tuple[Path, str]:
|
|
|
68
74
|
return candidate, rel_posix
|
|
69
75
|
|
|
70
76
|
|
|
77
|
+
_LOCAL_ARCHIVE_DIRS = {"archived_tickets", "archived_runs"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalize_local_archive_rel_path(base: Path, rel_path: str) -> tuple[Path, str]:
|
|
81
|
+
target, rel_posix = _normalize_archive_rel_path(base, rel_path)
|
|
82
|
+
if rel_posix:
|
|
83
|
+
head = rel_posix.split("/", 1)[0]
|
|
84
|
+
if head not in _LOCAL_ARCHIVE_DIRS:
|
|
85
|
+
raise ValueError("invalid archive path")
|
|
86
|
+
return target, rel_posix
|
|
87
|
+
|
|
88
|
+
|
|
71
89
|
def _resolve_snapshot_root(
|
|
72
90
|
repo_root: Path,
|
|
73
91
|
snapshot_id: str,
|
|
@@ -108,6 +126,15 @@ def _resolve_snapshot_root(
|
|
|
108
126
|
return resolved_root, worktree_id
|
|
109
127
|
|
|
110
128
|
|
|
129
|
+
def _resolve_local_run_root(repo_root: Path, run_id: str) -> Path:
|
|
130
|
+
run_id = _normalize_component(run_id, "run_id")
|
|
131
|
+
flows_root = _local_flows_root(repo_root)
|
|
132
|
+
run_root = flows_root / run_id
|
|
133
|
+
if not run_root.exists() or not run_root.is_dir():
|
|
134
|
+
raise FileNotFoundError("run archive not found")
|
|
135
|
+
return run_root
|
|
136
|
+
|
|
137
|
+
|
|
111
138
|
def _safe_mtime(path: Path) -> Optional[float]:
|
|
112
139
|
try:
|
|
113
140
|
return path.stat().st_mtime
|
|
@@ -194,6 +221,42 @@ def _iter_snapshots(repo_root: Path) -> list[ArchiveSnapshotSummary]:
|
|
|
194
221
|
return snapshots
|
|
195
222
|
|
|
196
223
|
|
|
224
|
+
def _format_mtime(ts: Optional[float]) -> Optional[str]:
|
|
225
|
+
if ts is None:
|
|
226
|
+
return None
|
|
227
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _iter_local_run_archives(repo_root: Path) -> list[LocalRunArchiveSummary]:
|
|
231
|
+
flows_root = _local_flows_root(repo_root)
|
|
232
|
+
if not flows_root.exists() or not flows_root.is_dir():
|
|
233
|
+
return []
|
|
234
|
+
entries: list[tuple[float, LocalRunArchiveSummary]] = []
|
|
235
|
+
for run_dir in sorted(flows_root.iterdir(), key=lambda p: p.name):
|
|
236
|
+
if not run_dir.is_dir():
|
|
237
|
+
continue
|
|
238
|
+
tickets_dir = run_dir / "archived_tickets"
|
|
239
|
+
runs_dir = run_dir / "archived_runs"
|
|
240
|
+
has_tickets = tickets_dir.exists() and tickets_dir.is_dir()
|
|
241
|
+
has_runs = runs_dir.exists() and runs_dir.is_dir()
|
|
242
|
+
if not has_tickets and not has_runs:
|
|
243
|
+
continue
|
|
244
|
+
mtime_candidates = [
|
|
245
|
+
_safe_mtime(tickets_dir) if has_tickets else None,
|
|
246
|
+
_safe_mtime(runs_dir) if has_runs else None,
|
|
247
|
+
]
|
|
248
|
+
mtime = max([ts for ts in mtime_candidates if ts is not None], default=0.0)
|
|
249
|
+
summary = LocalRunArchiveSummary(
|
|
250
|
+
run_id=run_dir.name,
|
|
251
|
+
archived_at=_format_mtime(mtime) if mtime else None,
|
|
252
|
+
has_tickets=has_tickets,
|
|
253
|
+
has_runs=has_runs,
|
|
254
|
+
)
|
|
255
|
+
entries.append((mtime, summary))
|
|
256
|
+
entries.sort(key=lambda item: (item[0], item[1].run_id), reverse=True)
|
|
257
|
+
return [entry[1] for entry in entries]
|
|
258
|
+
|
|
259
|
+
|
|
197
260
|
def _list_tree(snapshot_root: Path, rel_path: str) -> ArchiveTreeResponse:
|
|
198
261
|
target, rel_posix = _normalize_archive_rel_path(snapshot_root, rel_path)
|
|
199
262
|
if (
|
|
@@ -207,6 +270,69 @@ def _list_tree(snapshot_root: Path, rel_path: str) -> ArchiveTreeResponse:
|
|
|
207
270
|
|
|
208
271
|
root_real = snapshot_root.resolve(strict=False)
|
|
209
272
|
nodes: list[dict[str, Any]] = []
|
|
273
|
+
for child in sorted(
|
|
274
|
+
target.iterdir(), key=lambda p: p.name
|
|
275
|
+
): # codeql[py/path-injection] target validated by normalize helper
|
|
276
|
+
try:
|
|
277
|
+
resolved = child.resolve(strict=False)
|
|
278
|
+
resolved.relative_to(root_real)
|
|
279
|
+
except Exception:
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
if child.is_dir():
|
|
283
|
+
node_type = "folder"
|
|
284
|
+
size_bytes = None
|
|
285
|
+
else:
|
|
286
|
+
node_type = "file"
|
|
287
|
+
try:
|
|
288
|
+
size_bytes = child.stat().st_size
|
|
289
|
+
except OSError:
|
|
290
|
+
size_bytes = None
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
node_path = resolved.relative_to(root_real).as_posix()
|
|
294
|
+
except ValueError:
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
nodes.append(
|
|
298
|
+
{
|
|
299
|
+
"path": node_path,
|
|
300
|
+
"name": child.name,
|
|
301
|
+
"type": node_type,
|
|
302
|
+
"size_bytes": size_bytes,
|
|
303
|
+
"mtime": _safe_mtime(child),
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return ArchiveTreeResponse(path=rel_posix, nodes=nodes)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _list_local_tree(run_root: Path, rel_path: str) -> ArchiveTreeResponse:
|
|
311
|
+
target, rel_posix = _normalize_local_archive_rel_path(run_root, rel_path)
|
|
312
|
+
if not rel_posix:
|
|
313
|
+
nodes: list[dict[str, Any]] = []
|
|
314
|
+
for name in sorted(_LOCAL_ARCHIVE_DIRS):
|
|
315
|
+
candidate = run_root / name
|
|
316
|
+
if not candidate.exists() or not candidate.is_dir():
|
|
317
|
+
continue
|
|
318
|
+
nodes.append(
|
|
319
|
+
{
|
|
320
|
+
"path": name,
|
|
321
|
+
"name": name,
|
|
322
|
+
"type": "folder",
|
|
323
|
+
"size_bytes": None,
|
|
324
|
+
"mtime": _safe_mtime(candidate),
|
|
325
|
+
}
|
|
326
|
+
)
|
|
327
|
+
return ArchiveTreeResponse(path="", nodes=nodes)
|
|
328
|
+
|
|
329
|
+
if not target.exists():
|
|
330
|
+
raise FileNotFoundError("path not found")
|
|
331
|
+
if not target.is_dir():
|
|
332
|
+
raise ValueError("path is not a directory")
|
|
333
|
+
|
|
334
|
+
root_real = run_root.resolve(strict=False)
|
|
335
|
+
nodes: list[dict[str, Any]] = []
|
|
210
336
|
for child in sorted(target.iterdir(), key=lambda p: p.name):
|
|
211
337
|
try:
|
|
212
338
|
resolved = child.resolve(strict=False)
|
|
@@ -251,6 +377,12 @@ def build_archive_routes() -> APIRouter:
|
|
|
251
377
|
snapshots = _iter_snapshots(repo_root)
|
|
252
378
|
return {"snapshots": snapshots}
|
|
253
379
|
|
|
380
|
+
@router.get("/local-runs", response_model=LocalRunArchivesResponse)
|
|
381
|
+
def list_local_runs(request: Request):
|
|
382
|
+
repo_root = request.app.state.engine.repo_root
|
|
383
|
+
archives = _iter_local_run_archives(repo_root)
|
|
384
|
+
return {"archives": archives}
|
|
385
|
+
|
|
254
386
|
@router.get(
|
|
255
387
|
"/snapshots/{snapshot_id}", response_model=ArchiveSnapshotDetailResponse
|
|
256
388
|
)
|
|
@@ -294,6 +426,22 @@ def build_archive_routes() -> APIRouter:
|
|
|
294
426
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
295
427
|
return response
|
|
296
428
|
|
|
429
|
+
@router.get("/local/tree", response_model=ArchiveTreeResponse)
|
|
430
|
+
def list_local_tree(
|
|
431
|
+
request: Request,
|
|
432
|
+
run_id: str,
|
|
433
|
+
path: str = "",
|
|
434
|
+
):
|
|
435
|
+
repo_root = request.app.state.engine.repo_root
|
|
436
|
+
try:
|
|
437
|
+
run_root = _resolve_local_run_root(repo_root, run_id)
|
|
438
|
+
response = _list_local_tree(run_root, path)
|
|
439
|
+
except ValueError as exc:
|
|
440
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
441
|
+
except FileNotFoundError as exc:
|
|
442
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
443
|
+
return response
|
|
444
|
+
|
|
297
445
|
@router.get("/file", response_class=PlainTextResponse)
|
|
298
446
|
def read_file(
|
|
299
447
|
request: Request,
|
|
@@ -317,6 +465,32 @@ def build_archive_routes() -> APIRouter:
|
|
|
317
465
|
if not target.exists() or target.is_dir():
|
|
318
466
|
raise HTTPException(status_code=404, detail="file not found")
|
|
319
467
|
|
|
468
|
+
try:
|
|
469
|
+
content = target.read_text(
|
|
470
|
+
encoding="utf-8", errors="replace"
|
|
471
|
+
) # codeql[py/path-injection] target validated by normalize helper
|
|
472
|
+
except OSError as exc:
|
|
473
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
474
|
+
return PlainTextResponse(content)
|
|
475
|
+
|
|
476
|
+
@router.get("/local/file", response_class=PlainTextResponse)
|
|
477
|
+
def read_local_file(
|
|
478
|
+
request: Request,
|
|
479
|
+
run_id: str,
|
|
480
|
+
path: str,
|
|
481
|
+
):
|
|
482
|
+
repo_root = request.app.state.engine.repo_root
|
|
483
|
+
try:
|
|
484
|
+
run_root = _resolve_local_run_root(repo_root, run_id)
|
|
485
|
+
target, rel_posix = _normalize_local_archive_rel_path(run_root, path)
|
|
486
|
+
except ValueError as exc:
|
|
487
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
488
|
+
except FileNotFoundError as exc:
|
|
489
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
490
|
+
|
|
491
|
+
if not rel_posix or not target.exists() or target.is_dir():
|
|
492
|
+
raise HTTPException(status_code=404, detail="file not found")
|
|
493
|
+
|
|
320
494
|
try:
|
|
321
495
|
content = target.read_text(encoding="utf-8", errors="replace")
|
|
322
496
|
except OSError as exc:
|
|
@@ -351,6 +525,29 @@ def build_archive_routes() -> APIRouter:
|
|
|
351
525
|
filename=target.name,
|
|
352
526
|
)
|
|
353
527
|
|
|
528
|
+
@router.get("/local/download")
|
|
529
|
+
def download_local_file(
|
|
530
|
+
request: Request,
|
|
531
|
+
run_id: str,
|
|
532
|
+
path: str,
|
|
533
|
+
):
|
|
534
|
+
repo_root = request.app.state.engine.repo_root
|
|
535
|
+
try:
|
|
536
|
+
run_root = _resolve_local_run_root(repo_root, run_id)
|
|
537
|
+
target, rel_posix = _normalize_local_archive_rel_path(run_root, path)
|
|
538
|
+
except ValueError as exc:
|
|
539
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
540
|
+
except FileNotFoundError as exc:
|
|
541
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
542
|
+
|
|
543
|
+
if not rel_posix or not target.exists() or target.is_dir():
|
|
544
|
+
raise HTTPException(status_code=404, detail="file not found")
|
|
545
|
+
|
|
546
|
+
return FileResponse(
|
|
547
|
+
path=target, # codeql[py/path-injection] target validated by normalize helper
|
|
548
|
+
filename=target.name,
|
|
549
|
+
)
|
|
550
|
+
|
|
354
551
|
return router
|
|
355
552
|
|
|
356
553
|
|