loki-mode 5.40.0 → 5.41.0
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.
- package/SKILL.md +3 -3
- package/VERSION +1 -1
- package/autonomy/hooks/validate-bash.sh +12 -0
- package/autonomy/loki +186 -4
- package/autonomy/run.sh +163 -14
- package/dashboard/__init__.py +1 -1
- package/dashboard/auth.py +44 -11
- package/dashboard/control.py +101 -2
- package/dashboard/registry.py +3 -2
- package/dashboard/server.py +191 -6
- package/dashboard/static/index.html +8 -8
- package/dashboard/telemetry.py +4 -1
- package/docs/INSTALLATION.md +1 -1
- package/events/emit.sh +22 -1
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/skills/github-integration.md +43 -11
package/dashboard/control.py
CHANGED
|
@@ -11,10 +11,12 @@ Usage:
|
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
import asyncio
|
|
14
|
+
import fcntl
|
|
14
15
|
import json
|
|
15
16
|
import os
|
|
16
17
|
import signal
|
|
17
18
|
import subprocess
|
|
19
|
+
import tempfile
|
|
18
20
|
from datetime import datetime, timezone
|
|
19
21
|
from pathlib import Path
|
|
20
22
|
from typing import Optional
|
|
@@ -49,6 +51,57 @@ RUN_SH = SKILL_DIR / "autonomy" / "run.sh"
|
|
|
49
51
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
50
52
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
51
53
|
|
|
54
|
+
# Utility: atomic write with optional file locking
|
|
55
|
+
def atomic_write_json(file_path: Path, data: dict, use_lock: bool = True):
|
|
56
|
+
"""
|
|
57
|
+
Atomically write JSON data to a file to prevent TOCTOU race conditions.
|
|
58
|
+
Uses temporary file + os.rename() for atomicity.
|
|
59
|
+
Optionally uses fcntl.flock for additional safety.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
# Write to temporary file in same directory (for atomic rename)
|
|
63
|
+
temp_fd, temp_path = tempfile.mkstemp(
|
|
64
|
+
dir=file_path.parent,
|
|
65
|
+
prefix=f".{file_path.name}.",
|
|
66
|
+
suffix=".tmp"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
with os.fdopen(temp_fd, 'w') as f:
|
|
71
|
+
# Acquire exclusive lock if requested
|
|
72
|
+
if use_lock:
|
|
73
|
+
try:
|
|
74
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
75
|
+
except (OSError, AttributeError):
|
|
76
|
+
# flock not available on this platform - continue without lock
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
# Write data
|
|
80
|
+
json.dump(data, f, indent=2)
|
|
81
|
+
f.flush()
|
|
82
|
+
os.fsync(f.fileno())
|
|
83
|
+
|
|
84
|
+
# Release lock (happens automatically on close, but explicit is clearer)
|
|
85
|
+
if use_lock:
|
|
86
|
+
try:
|
|
87
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
88
|
+
except (OSError, AttributeError):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
# Atomic rename
|
|
92
|
+
os.rename(temp_path, file_path)
|
|
93
|
+
|
|
94
|
+
except Exception:
|
|
95
|
+
# Clean up temp file on error
|
|
96
|
+
try:
|
|
97
|
+
os.unlink(temp_path)
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
except Exception as e:
|
|
103
|
+
raise RuntimeError(f"Failed to write {file_path}: {e}")
|
|
104
|
+
|
|
52
105
|
# FastAPI app
|
|
53
106
|
app = FastAPI(
|
|
54
107
|
title="Loki Mode Control API",
|
|
@@ -77,6 +130,42 @@ class StartRequest(BaseModel):
|
|
|
77
130
|
parallel: bool = False
|
|
78
131
|
background: bool = True
|
|
79
132
|
|
|
133
|
+
def validate_provider(self) -> None:
|
|
134
|
+
"""Validate provider is from allowed list."""
|
|
135
|
+
allowed_providers = ["claude", "codex", "gemini"]
|
|
136
|
+
if self.provider not in allowed_providers:
|
|
137
|
+
raise ValueError(f"Invalid provider: {self.provider}. Must be one of: {', '.join(allowed_providers)}")
|
|
138
|
+
|
|
139
|
+
def validate_prd_path(self) -> None:
|
|
140
|
+
"""Validate PRD path is safe and exists."""
|
|
141
|
+
if not self.prd:
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
# Check for path traversal sequences
|
|
145
|
+
if ".." in self.prd:
|
|
146
|
+
raise ValueError("PRD path contains path traversal sequence (..)")
|
|
147
|
+
|
|
148
|
+
# Resolve to absolute path and verify it exists
|
|
149
|
+
prd_path = Path(self.prd).resolve()
|
|
150
|
+
if not prd_path.exists():
|
|
151
|
+
raise ValueError(f"PRD file does not exist: {self.prd}")
|
|
152
|
+
|
|
153
|
+
# Verify it's a file, not a directory
|
|
154
|
+
if not prd_path.is_file():
|
|
155
|
+
raise ValueError(f"PRD path is not a file: {self.prd}")
|
|
156
|
+
|
|
157
|
+
# Verify path resolves within CWD or a reasonable parent
|
|
158
|
+
cwd = Path.cwd().resolve()
|
|
159
|
+
try:
|
|
160
|
+
prd_path.relative_to(cwd)
|
|
161
|
+
except ValueError:
|
|
162
|
+
# Not within CWD - check if it's within user's home or project directory
|
|
163
|
+
home = Path.home().resolve()
|
|
164
|
+
try:
|
|
165
|
+
prd_path.relative_to(home)
|
|
166
|
+
except ValueError:
|
|
167
|
+
raise ValueError(f"PRD path is outside allowed directories: {self.prd}")
|
|
168
|
+
|
|
80
169
|
|
|
81
170
|
class StatusResponse(BaseModel):
|
|
82
171
|
"""Current session status."""
|
|
@@ -272,6 +361,13 @@ async def start_session(request: StartRequest):
|
|
|
272
361
|
Returns:
|
|
273
362
|
ControlResponse with success status and PID
|
|
274
363
|
"""
|
|
364
|
+
# Validate input
|
|
365
|
+
try:
|
|
366
|
+
request.validate_provider()
|
|
367
|
+
request.validate_prd_path()
|
|
368
|
+
except ValueError as e:
|
|
369
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
370
|
+
|
|
275
371
|
# Check if already running
|
|
276
372
|
status = get_status()
|
|
277
373
|
if status.state == "running":
|
|
@@ -356,10 +452,13 @@ async def stop_session():
|
|
|
356
452
|
session_file = LOKI_DIR / "session.json"
|
|
357
453
|
if session_file.exists():
|
|
358
454
|
try:
|
|
455
|
+
# Read current session data
|
|
359
456
|
session_data = json.loads(session_file.read_text())
|
|
360
457
|
session_data["status"] = "stopped"
|
|
361
|
-
|
|
362
|
-
|
|
458
|
+
|
|
459
|
+
# Atomic write with file locking to prevent race conditions
|
|
460
|
+
atomic_write_json(session_file, session_data, use_lock=True)
|
|
461
|
+
except (json.JSONDecodeError, KeyError, RuntimeError):
|
|
363
462
|
pass
|
|
364
463
|
|
|
365
464
|
# Emit stop event
|
package/dashboard/registry.py
CHANGED
|
@@ -44,7 +44,7 @@ def _save_registry(registry: dict) -> None:
|
|
|
44
44
|
|
|
45
45
|
def _generate_project_id(path: str) -> str:
|
|
46
46
|
"""Generate a unique project ID from path."""
|
|
47
|
-
return hashlib.
|
|
47
|
+
return hashlib.sha256(path.encode()).hexdigest()[:12]
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def register_project(
|
|
@@ -286,7 +286,8 @@ def discover_projects(
|
|
|
286
286
|
|
|
287
287
|
# Search subdirectories
|
|
288
288
|
for child in path.iterdir():
|
|
289
|
-
|
|
289
|
+
# Skip symlinks to avoid following into unexpected directories
|
|
290
|
+
if child.is_dir() and not child.name.startswith(".") and not child.is_symlink():
|
|
290
291
|
search_dir(child, depth + 1)
|
|
291
292
|
|
|
292
293
|
except (PermissionError, OSError):
|
package/dashboard/server.py
CHANGED
|
@@ -68,14 +68,33 @@ LOKI_TLS_KEY = os.environ.get("LOKI_TLS_KEY", "") # Path to PEM private key
|
|
|
68
68
|
class _RateLimiter:
|
|
69
69
|
"""Simple in-memory rate limiter for control endpoints."""
|
|
70
70
|
|
|
71
|
-
def __init__(self, max_calls: int = 10, window_seconds: int = 60):
|
|
71
|
+
def __init__(self, max_calls: int = 10, window_seconds: int = 60, max_keys: int = 10000):
|
|
72
72
|
self._max_calls = max_calls
|
|
73
73
|
self._window = window_seconds
|
|
74
|
+
self._max_keys = max_keys
|
|
74
75
|
self._calls: dict[str, list[float]] = defaultdict(list)
|
|
75
76
|
|
|
76
77
|
def check(self, key: str) -> bool:
|
|
77
78
|
now = time.time()
|
|
79
|
+
# Prune old timestamps for this key
|
|
78
80
|
self._calls[key] = [t for t in self._calls[key] if now - t < self._window]
|
|
81
|
+
|
|
82
|
+
# Remove keys with empty timestamp lists
|
|
83
|
+
empty_keys = [k for k, v in self._calls.items() if not v]
|
|
84
|
+
for k in empty_keys:
|
|
85
|
+
del self._calls[k]
|
|
86
|
+
|
|
87
|
+
# Evict oldest keys if max_keys exceeded
|
|
88
|
+
if len(self._calls) > self._max_keys:
|
|
89
|
+
# Sort by oldest timestamp, remove oldest keys
|
|
90
|
+
sorted_keys = sorted(
|
|
91
|
+
self._calls.items(),
|
|
92
|
+
key=lambda x: min(x[1]) if x[1] else 0
|
|
93
|
+
)
|
|
94
|
+
keys_to_remove = len(self._calls) - self._max_keys
|
|
95
|
+
for k, _ in sorted_keys[:keys_to_remove]:
|
|
96
|
+
del self._calls[k]
|
|
97
|
+
|
|
79
98
|
if len(self._calls[key]) >= self._max_calls:
|
|
80
99
|
return False
|
|
81
100
|
self._calls[key].append(now)
|
|
@@ -83,6 +102,7 @@ class _RateLimiter:
|
|
|
83
102
|
|
|
84
103
|
|
|
85
104
|
_control_limiter = _RateLimiter(max_calls=10, window_seconds=60)
|
|
105
|
+
_read_limiter = _RateLimiter(max_calls=60, window_seconds=60)
|
|
86
106
|
|
|
87
107
|
# Set up logging
|
|
88
108
|
logger = logging.getLogger(__name__)
|
|
@@ -193,11 +213,18 @@ class StatusResponse(BaseModel):
|
|
|
193
213
|
class ConnectionManager:
|
|
194
214
|
"""Manages WebSocket connections for real-time updates."""
|
|
195
215
|
|
|
216
|
+
MAX_CONNECTIONS = int(os.environ.get("LOKI_MAX_WS_CONNECTIONS", "100"))
|
|
217
|
+
|
|
196
218
|
def __init__(self):
|
|
197
219
|
self.active_connections: list[WebSocket] = []
|
|
198
220
|
|
|
199
221
|
async def connect(self, websocket: WebSocket) -> None:
|
|
200
222
|
"""Accept a new WebSocket connection."""
|
|
223
|
+
if len(self.active_connections) >= self.MAX_CONNECTIONS:
|
|
224
|
+
await websocket.accept()
|
|
225
|
+
await websocket.close(code=1013, reason="Connection limit reached. Try again later.")
|
|
226
|
+
logger.warning(f"WebSocket connection rejected: limit of {self.MAX_CONNECTIONS} reached")
|
|
227
|
+
return
|
|
201
228
|
await websocket.accept()
|
|
202
229
|
self.active_connections.append(websocket)
|
|
203
230
|
|
|
@@ -853,6 +880,13 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
|
853
880
|
# Authorization headers on WS upgrade. Tokens may appear in reverse
|
|
854
881
|
# proxy access logs -- configure log sanitization for /ws in production.
|
|
855
882
|
# FastAPI Depends() is not supported on @app.websocket() routes.
|
|
883
|
+
|
|
884
|
+
# Rate limit WebSocket connections by IP
|
|
885
|
+
client_ip = websocket.client.host if websocket.client else "unknown"
|
|
886
|
+
if not _read_limiter.check(f"ws_{client_ip}"):
|
|
887
|
+
await websocket.close(code=1008) # Policy Violation
|
|
888
|
+
return
|
|
889
|
+
|
|
856
890
|
if auth.is_enterprise_mode() or auth.is_oidc_mode():
|
|
857
891
|
ws_token: Optional[str] = websocket.query_params.get("token")
|
|
858
892
|
if not ws_token:
|
|
@@ -1019,8 +1053,9 @@ async def update_project_access(identifier: str):
|
|
|
1019
1053
|
|
|
1020
1054
|
|
|
1021
1055
|
@app.get("/api/registry/discover", response_model=list[DiscoverResponse])
|
|
1022
|
-
async def discover_projects(max_depth: int = 3):
|
|
1056
|
+
async def discover_projects(max_depth: int = Query(default=3, ge=1, le=10)):
|
|
1023
1057
|
"""Discover projects with .loki directories."""
|
|
1058
|
+
max_depth = min(max_depth, 10)
|
|
1024
1059
|
discovered = registry.discover_projects(max_depth=max_depth)
|
|
1025
1060
|
return discovered
|
|
1026
1061
|
|
|
@@ -1028,6 +1063,9 @@ async def discover_projects(max_depth: int = 3):
|
|
|
1028
1063
|
@app.post("/api/registry/sync", response_model=SyncResponse)
|
|
1029
1064
|
async def sync_registry():
|
|
1030
1065
|
"""Sync the registry with discovered projects."""
|
|
1066
|
+
if not _read_limiter.check("registry_sync"):
|
|
1067
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
1068
|
+
|
|
1031
1069
|
result = registry.sync_registry_with_discovery()
|
|
1032
1070
|
return {
|
|
1033
1071
|
"added": result["added"],
|
|
@@ -1109,6 +1147,9 @@ async def create_token(request: TokenCreateRequest):
|
|
|
1109
1147
|
|
|
1110
1148
|
The raw token is only returned once on creation - save it securely.
|
|
1111
1149
|
"""
|
|
1150
|
+
if not _read_limiter.check("token_create"):
|
|
1151
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
1152
|
+
|
|
1112
1153
|
if not auth.is_enterprise_mode():
|
|
1113
1154
|
raise HTTPException(
|
|
1114
1155
|
status_code=403,
|
|
@@ -1562,6 +1603,9 @@ async def get_learning_aggregation():
|
|
|
1562
1603
|
@app.post("/api/learning/aggregate", dependencies=[Depends(auth.require_scope("control"))])
|
|
1563
1604
|
async def trigger_aggregation():
|
|
1564
1605
|
"""Aggregate learning signals from events.jsonl into structured metrics."""
|
|
1606
|
+
if not _read_limiter.check("learning_aggregate"):
|
|
1607
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
1608
|
+
|
|
1565
1609
|
events_file = _get_loki_dir() / "events.jsonl"
|
|
1566
1610
|
preferences: dict = {}
|
|
1567
1611
|
error_patterns: dict = {}
|
|
@@ -1693,17 +1737,34 @@ def _parse_time_range(time_range: str) -> Optional[datetime]:
|
|
|
1693
1737
|
return datetime.now(timezone.utc) - delta
|
|
1694
1738
|
|
|
1695
1739
|
|
|
1696
|
-
def _read_events(time_range: str = "7d") -> list:
|
|
1697
|
-
"""Read events from .loki/events.jsonl with time filter."""
|
|
1740
|
+
def _read_events(time_range: str = "7d", max_events: int = 10000) -> list:
|
|
1741
|
+
"""Read events from .loki/events.jsonl with time filter and size limits."""
|
|
1698
1742
|
events_file = _get_loki_dir() / "events.jsonl"
|
|
1699
1743
|
if not events_file.exists():
|
|
1700
1744
|
return []
|
|
1701
1745
|
|
|
1702
1746
|
cutoff = _parse_time_range(time_range)
|
|
1703
1747
|
events = []
|
|
1748
|
+
max_file_size = 10 * 1024 * 1024 # 10MB
|
|
1749
|
+
|
|
1704
1750
|
try:
|
|
1705
|
-
|
|
1706
|
-
|
|
1751
|
+
file_size = events_file.stat().st_size
|
|
1752
|
+
|
|
1753
|
+
# If file > 10MB, seek to last 10MB
|
|
1754
|
+
with open(events_file, 'r') as f:
|
|
1755
|
+
if file_size > max_file_size:
|
|
1756
|
+
f.seek(max(0, file_size - max_file_size))
|
|
1757
|
+
# Skip partial first line after seek
|
|
1758
|
+
f.readline()
|
|
1759
|
+
|
|
1760
|
+
for line in f:
|
|
1761
|
+
if len(events) >= max_events:
|
|
1762
|
+
break
|
|
1763
|
+
|
|
1764
|
+
line = line.strip()
|
|
1765
|
+
if not line:
|
|
1766
|
+
continue
|
|
1767
|
+
|
|
1707
1768
|
try:
|
|
1708
1769
|
event = json.loads(line)
|
|
1709
1770
|
# Filter by time_range if cutoff was parsed successfully
|
|
@@ -2728,6 +2789,130 @@ async def get_secrets_status():
|
|
|
2728
2789
|
}
|
|
2729
2790
|
|
|
2730
2791
|
|
|
2792
|
+
# =============================================================================
|
|
2793
|
+
# GitHub Integration API (v5.41.0)
|
|
2794
|
+
# =============================================================================
|
|
2795
|
+
|
|
2796
|
+
|
|
2797
|
+
@app.get("/api/github/status")
|
|
2798
|
+
async def get_github_status(token: Optional[dict] = Depends(auth.get_current_token)):
|
|
2799
|
+
"""Get GitHub integration status and configuration."""
|
|
2800
|
+
loki_dir = _get_loki_dir()
|
|
2801
|
+
result: dict[str, Any] = {
|
|
2802
|
+
"import_enabled": os.environ.get("LOKI_GITHUB_IMPORT", "false") == "true",
|
|
2803
|
+
"sync_enabled": os.environ.get("LOKI_GITHUB_SYNC", "false") == "true",
|
|
2804
|
+
"pr_enabled": os.environ.get("LOKI_GITHUB_PR", "false") == "true",
|
|
2805
|
+
"labels_filter": os.environ.get("LOKI_GITHUB_LABELS", ""),
|
|
2806
|
+
"milestone_filter": os.environ.get("LOKI_GITHUB_MILESTONE", ""),
|
|
2807
|
+
"limit": int(os.environ.get("LOKI_GITHUB_LIMIT", "100")),
|
|
2808
|
+
"imported_tasks": 0,
|
|
2809
|
+
"synced_updates": 0,
|
|
2810
|
+
"repo": None,
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
# Count imported GitHub tasks from pending queue
|
|
2814
|
+
pending_file = loki_dir / "queue" / "pending.json"
|
|
2815
|
+
if pending_file.exists():
|
|
2816
|
+
try:
|
|
2817
|
+
data = json.loads(pending_file.read_text())
|
|
2818
|
+
tasks = data.get("tasks", data) if isinstance(data, dict) else data
|
|
2819
|
+
result["imported_tasks"] = sum(1 for t in tasks if t.get("source") == "github")
|
|
2820
|
+
except Exception:
|
|
2821
|
+
pass
|
|
2822
|
+
|
|
2823
|
+
# Count sync log entries
|
|
2824
|
+
sync_log = loki_dir / "github" / "synced.log"
|
|
2825
|
+
if sync_log.exists():
|
|
2826
|
+
try:
|
|
2827
|
+
result["synced_updates"] = sum(1 for _ in sync_log.open())
|
|
2828
|
+
except Exception:
|
|
2829
|
+
pass
|
|
2830
|
+
|
|
2831
|
+
# Detect repo from git
|
|
2832
|
+
try:
|
|
2833
|
+
import subprocess
|
|
2834
|
+
url = subprocess.run(
|
|
2835
|
+
["git", "remote", "get-url", "origin"],
|
|
2836
|
+
capture_output=True, text=True, timeout=5,
|
|
2837
|
+
cwd=str(loki_dir.parent) if loki_dir.name == ".loki" else None
|
|
2838
|
+
)
|
|
2839
|
+
if url.returncode == 0:
|
|
2840
|
+
repo = url.stdout.strip()
|
|
2841
|
+
# Parse owner/repo from URL
|
|
2842
|
+
for prefix in ["https://github.com/", "git@github.com:"]:
|
|
2843
|
+
if repo.startswith(prefix):
|
|
2844
|
+
repo = repo[len(prefix):]
|
|
2845
|
+
break
|
|
2846
|
+
result["repo"] = repo.removesuffix(".git")
|
|
2847
|
+
except Exception:
|
|
2848
|
+
pass
|
|
2849
|
+
|
|
2850
|
+
return result
|
|
2851
|
+
|
|
2852
|
+
|
|
2853
|
+
@app.get("/api/github/tasks")
|
|
2854
|
+
async def get_github_tasks(token: Optional[dict] = Depends(auth.get_current_token)):
|
|
2855
|
+
"""Get all GitHub-sourced tasks and their sync status."""
|
|
2856
|
+
loki_dir = _get_loki_dir()
|
|
2857
|
+
tasks: list[dict] = []
|
|
2858
|
+
|
|
2859
|
+
# Collect GitHub tasks from all queues
|
|
2860
|
+
for queue_name in ["pending", "in-progress", "completed", "failed"]:
|
|
2861
|
+
queue_file = loki_dir / "queue" / f"{queue_name}.json"
|
|
2862
|
+
if queue_file.exists():
|
|
2863
|
+
try:
|
|
2864
|
+
data = json.loads(queue_file.read_text())
|
|
2865
|
+
items = data.get("tasks", data) if isinstance(data, dict) else data
|
|
2866
|
+
for t in items:
|
|
2867
|
+
if t.get("source") == "github" or str(t.get("id", "")).startswith("github-"):
|
|
2868
|
+
t["queue"] = queue_name
|
|
2869
|
+
tasks.append(t)
|
|
2870
|
+
except Exception:
|
|
2871
|
+
pass
|
|
2872
|
+
|
|
2873
|
+
# Load sync log to annotate sync status
|
|
2874
|
+
synced: set[str] = set()
|
|
2875
|
+
sync_log = loki_dir / "github" / "synced.log"
|
|
2876
|
+
if sync_log.exists():
|
|
2877
|
+
try:
|
|
2878
|
+
synced = set(sync_log.read_text().strip().splitlines())
|
|
2879
|
+
except Exception:
|
|
2880
|
+
pass
|
|
2881
|
+
|
|
2882
|
+
for t in tasks:
|
|
2883
|
+
issue_num = str(t.get("github_issue", ""))
|
|
2884
|
+
if not issue_num:
|
|
2885
|
+
issue_num = str(t.get("id", "")).replace("github-", "")
|
|
2886
|
+
t["synced_statuses"] = [
|
|
2887
|
+
s.split(":")[1] for s in synced if s.startswith(f"{issue_num}:")
|
|
2888
|
+
]
|
|
2889
|
+
|
|
2890
|
+
return {"tasks": tasks, "total": len(tasks)}
|
|
2891
|
+
|
|
2892
|
+
|
|
2893
|
+
@app.get("/api/github/sync-log")
|
|
2894
|
+
async def get_github_sync_log(
|
|
2895
|
+
limit: int = Query(default=50, ge=1, le=500),
|
|
2896
|
+
token: Optional[dict] = Depends(auth.get_current_token)
|
|
2897
|
+
):
|
|
2898
|
+
"""Get the GitHub sync log (status updates sent to issues)."""
|
|
2899
|
+
loki_dir = _get_loki_dir()
|
|
2900
|
+
sync_log = loki_dir / "github" / "synced.log"
|
|
2901
|
+
entries: list[dict] = []
|
|
2902
|
+
|
|
2903
|
+
if sync_log.exists():
|
|
2904
|
+
try:
|
|
2905
|
+
lines = sync_log.read_text().strip().splitlines()
|
|
2906
|
+
for line in lines[-limit:]:
|
|
2907
|
+
parts = line.split(":", 1)
|
|
2908
|
+
if len(parts) == 2:
|
|
2909
|
+
entries.append({"issue": parts[0], "status": parts[1]})
|
|
2910
|
+
except Exception:
|
|
2911
|
+
pass
|
|
2912
|
+
|
|
2913
|
+
return {"entries": entries, "total": len(entries)}
|
|
2914
|
+
|
|
2915
|
+
|
|
2731
2916
|
# =============================================================================
|
|
2732
2917
|
# Process Health / Watchdog API
|
|
2733
2918
|
# =============================================================================
|