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.
Files changed (67) hide show
  1. codex_autorunner/bootstrap.py +26 -5
  2. codex_autorunner/core/about_car.py +12 -12
  3. codex_autorunner/core/config.py +178 -61
  4. codex_autorunner/core/context_awareness.py +1 -0
  5. codex_autorunner/core/filesystem.py +24 -0
  6. codex_autorunner/core/flows/controller.py +50 -12
  7. codex_autorunner/core/flows/runtime.py +8 -3
  8. codex_autorunner/core/hub.py +293 -16
  9. codex_autorunner/core/lifecycle_events.py +44 -5
  10. codex_autorunner/core/pma_context.py +188 -1
  11. codex_autorunner/core/pma_delivery.py +81 -0
  12. codex_autorunner/core/pma_dispatches.py +224 -0
  13. codex_autorunner/core/pma_lane_worker.py +122 -0
  14. codex_autorunner/core/pma_queue.py +167 -18
  15. codex_autorunner/core/pma_reactive.py +91 -0
  16. codex_autorunner/core/pma_safety.py +58 -0
  17. codex_autorunner/core/pma_sink.py +104 -0
  18. codex_autorunner/core/pma_transcripts.py +183 -0
  19. codex_autorunner/core/safe_paths.py +117 -0
  20. codex_autorunner/housekeeping.py +77 -23
  21. codex_autorunner/integrations/agents/codex_backend.py +18 -12
  22. codex_autorunner/integrations/agents/wiring.py +2 -0
  23. codex_autorunner/integrations/app_server/client.py +31 -0
  24. codex_autorunner/integrations/app_server/supervisor.py +3 -0
  25. codex_autorunner/integrations/telegram/adapter.py +1 -1
  26. codex_autorunner/integrations/telegram/config.py +1 -1
  27. codex_autorunner/integrations/telegram/constants.py +1 -1
  28. codex_autorunner/integrations/telegram/handlers/commands/execution.py +16 -15
  29. codex_autorunner/integrations/telegram/handlers/commands/files.py +5 -8
  30. codex_autorunner/integrations/telegram/handlers/commands/github.py +10 -6
  31. codex_autorunner/integrations/telegram/handlers/commands/shared.py +9 -8
  32. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +85 -2
  33. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +29 -8
  34. codex_autorunner/integrations/telegram/handlers/messages.py +8 -2
  35. codex_autorunner/integrations/telegram/helpers.py +30 -2
  36. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +54 -3
  37. codex_autorunner/static/archive.js +274 -81
  38. codex_autorunner/static/archiveApi.js +21 -0
  39. codex_autorunner/static/constants.js +1 -1
  40. codex_autorunner/static/docChatCore.js +2 -0
  41. codex_autorunner/static/hub.js +59 -0
  42. codex_autorunner/static/index.html +70 -54
  43. codex_autorunner/static/notificationBell.js +173 -0
  44. codex_autorunner/static/notifications.js +187 -36
  45. codex_autorunner/static/pma.js +96 -35
  46. codex_autorunner/static/styles.css +431 -4
  47. codex_autorunner/static/terminalManager.js +22 -3
  48. codex_autorunner/static/utils.js +5 -1
  49. codex_autorunner/surfaces/cli/cli.py +206 -129
  50. codex_autorunner/surfaces/cli/template_repos.py +157 -0
  51. codex_autorunner/surfaces/web/app.py +193 -5
  52. codex_autorunner/surfaces/web/routes/archive.py +197 -0
  53. codex_autorunner/surfaces/web/routes/file_chat.py +115 -87
  54. codex_autorunner/surfaces/web/routes/flows.py +125 -67
  55. codex_autorunner/surfaces/web/routes/pma.py +638 -57
  56. codex_autorunner/surfaces/web/schemas.py +11 -0
  57. codex_autorunner/tickets/agent_pool.py +6 -1
  58. codex_autorunner/tickets/outbox.py +27 -14
  59. codex_autorunner/tickets/replies.py +4 -10
  60. codex_autorunner/tickets/runner.py +1 -0
  61. codex_autorunner/workspace/paths.py +8 -3
  62. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/METADATA +1 -1
  63. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/RECORD +67 -57
  64. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/WHEEL +0 -0
  65. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/entry_points.txt +0 -0
  66. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/licenses/LICENSE +0 -0
  67. {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
- app.include_router(build_pma_routes())
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
- latest = _latest_dispatch(
1765
- repo_root, str(record.id), dict(record.input_data or {})
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