loki-mode 5.46.0 → 5.48.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/README.md +1 -1
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +11 -6
- package/autonomy/checklist-verify.py +3 -2
- package/autonomy/completion-council.sh +129 -2
- package/autonomy/loki +9 -1
- package/autonomy/playwright-verify.sh +0 -0
- package/autonomy/prd-analyzer.py +0 -0
- package/autonomy/prd-checklist.sh +163 -2
- package/autonomy/run.sh +14 -7
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +166 -18
- package/dashboard/static/index.html +161 -65
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/memory/embeddings.py +5 -0
- package/memory/engine.py +2 -1
- package/memory/retrieval.py +1 -0
- package/memory/token_economics.py +13 -2
- package/package.json +2 -2
package/dashboard/server.py
CHANGED
|
@@ -26,7 +26,7 @@ from fastapi import (
|
|
|
26
26
|
WebSocketDisconnect,
|
|
27
27
|
)
|
|
28
28
|
from fastapi.middleware.cors import CORSMiddleware
|
|
29
|
-
from fastapi.responses import PlainTextResponse
|
|
29
|
+
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
30
30
|
from pydantic import BaseModel, Field
|
|
31
31
|
from sqlalchemy import select, update, delete
|
|
32
32
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
@@ -62,6 +62,15 @@ except ImportError:
|
|
|
62
62
|
LOKI_TLS_CERT = os.environ.get("LOKI_TLS_CERT", "") # Path to PEM certificate
|
|
63
63
|
LOKI_TLS_KEY = os.environ.get("LOKI_TLS_KEY", "") # Path to PEM private key
|
|
64
64
|
|
|
65
|
+
|
|
66
|
+
def _safe_int_env(name: str, default: int) -> int:
|
|
67
|
+
"""Read an integer from an environment variable, returning *default* on bad values."""
|
|
68
|
+
try:
|
|
69
|
+
return int(os.environ.get(name, str(default)))
|
|
70
|
+
except (ValueError, TypeError):
|
|
71
|
+
return default
|
|
72
|
+
|
|
73
|
+
|
|
65
74
|
# ---------------------------------------------------------------------------
|
|
66
75
|
# Simple in-memory rate limiter for control endpoints
|
|
67
76
|
# ---------------------------------------------------------------------------
|
|
@@ -214,7 +223,10 @@ class StatusResponse(BaseModel):
|
|
|
214
223
|
class ConnectionManager:
|
|
215
224
|
"""Manages WebSocket connections for real-time updates."""
|
|
216
225
|
|
|
217
|
-
|
|
226
|
+
try:
|
|
227
|
+
MAX_CONNECTIONS = int(os.environ.get("LOKI_MAX_WS_CONNECTIONS", "100"))
|
|
228
|
+
except (ValueError, TypeError):
|
|
229
|
+
MAX_CONNECTIONS = 100
|
|
218
230
|
|
|
219
231
|
def __init__(self):
|
|
220
232
|
self.active_connections: list[WebSocket] = []
|
|
@@ -493,7 +505,7 @@ async def list_projects(
|
|
|
493
505
|
return response
|
|
494
506
|
|
|
495
507
|
|
|
496
|
-
@app.post("/api/projects", response_model=ProjectResponse, status_code=201)
|
|
508
|
+
@app.post("/api/projects", response_model=ProjectResponse, status_code=201, dependencies=[Depends(auth.require_scope("control"))])
|
|
497
509
|
async def create_project(
|
|
498
510
|
project: ProjectCreate,
|
|
499
511
|
db: AsyncSession = Depends(get_db),
|
|
@@ -718,7 +730,7 @@ async def list_tasks(
|
|
|
718
730
|
return all_tasks
|
|
719
731
|
|
|
720
732
|
|
|
721
|
-
@app.post("/api/tasks", response_model=TaskResponse, status_code=201)
|
|
733
|
+
@app.post("/api/tasks", response_model=TaskResponse, status_code=201, dependencies=[Depends(auth.require_scope("control"))])
|
|
722
734
|
async def create_task(
|
|
723
735
|
task: TaskCreate,
|
|
724
736
|
db: AsyncSession = Depends(get_db),
|
|
@@ -1041,7 +1053,7 @@ async def list_registered_projects(include_inactive: bool = False):
|
|
|
1041
1053
|
return projects
|
|
1042
1054
|
|
|
1043
1055
|
|
|
1044
|
-
@app.post("/api/registry/projects", response_model=RegisteredProjectResponse, status_code=201)
|
|
1056
|
+
@app.post("/api/registry/projects", response_model=RegisteredProjectResponse, status_code=201, dependencies=[Depends(auth.require_scope("control"))])
|
|
1045
1057
|
async def register_project(request: RegisterProjectRequest):
|
|
1046
1058
|
"""Register a new project."""
|
|
1047
1059
|
try:
|
|
@@ -1087,7 +1099,7 @@ async def get_project_health(identifier: str):
|
|
|
1087
1099
|
return health
|
|
1088
1100
|
|
|
1089
1101
|
|
|
1090
|
-
@app.post("/api/registry/projects/{identifier}/access")
|
|
1102
|
+
@app.post("/api/registry/projects/{identifier}/access", dependencies=[Depends(auth.require_scope("control"))])
|
|
1091
1103
|
async def update_project_access(identifier: str):
|
|
1092
1104
|
"""Update the last accessed timestamp for a project."""
|
|
1093
1105
|
project = registry.update_last_accessed(identifier)
|
|
@@ -1104,7 +1116,7 @@ async def discover_projects(max_depth: int = Query(default=3, ge=1, le=10)):
|
|
|
1104
1116
|
return discovered
|
|
1105
1117
|
|
|
1106
1118
|
|
|
1107
|
-
@app.post("/api/registry/sync", response_model=SyncResponse)
|
|
1119
|
+
@app.post("/api/registry/sync", response_model=SyncResponse, dependencies=[Depends(auth.require_scope("control"))])
|
|
1108
1120
|
async def sync_registry():
|
|
1109
1121
|
"""Sync the registry with discovered projects."""
|
|
1110
1122
|
if not _read_limiter.check("registry_sync"):
|
|
@@ -1792,7 +1804,17 @@ async def trigger_aggregation():
|
|
|
1792
1804
|
|
|
1793
1805
|
if events_file.exists():
|
|
1794
1806
|
try:
|
|
1795
|
-
|
|
1807
|
+
# Guard against unbounded reads: if file > 10 MB, read only the tail
|
|
1808
|
+
_MAX_EVENTS_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
1809
|
+
_fsize = events_file.stat().st_size
|
|
1810
|
+
if _fsize > _MAX_EVENTS_BYTES:
|
|
1811
|
+
with open(events_file, "rb") as _fh:
|
|
1812
|
+
_fh.seek(-_MAX_EVENTS_BYTES, 2)
|
|
1813
|
+
_fh.readline() # discard partial first line
|
|
1814
|
+
_raw_text = _fh.read().decode("utf-8", errors="replace")
|
|
1815
|
+
else:
|
|
1816
|
+
_raw_text = events_file.read_text()
|
|
1817
|
+
for raw_line in _raw_text.strip().split("\n"):
|
|
1796
1818
|
if not raw_line.strip():
|
|
1797
1819
|
continue
|
|
1798
1820
|
try:
|
|
@@ -2396,8 +2418,8 @@ async def get_council_convergence():
|
|
|
2396
2418
|
convergence_file = _get_loki_dir() / "council" / "convergence.log"
|
|
2397
2419
|
data_points = []
|
|
2398
2420
|
if convergence_file.exists():
|
|
2399
|
-
|
|
2400
|
-
|
|
2421
|
+
for line in convergence_file.read_text().strip().split("\n"):
|
|
2422
|
+
try:
|
|
2401
2423
|
parts = line.split("|")
|
|
2402
2424
|
if len(parts) >= 5:
|
|
2403
2425
|
data_points.append({
|
|
@@ -2407,8 +2429,8 @@ async def get_council_convergence():
|
|
|
2407
2429
|
"no_change_streak": int(parts[3]),
|
|
2408
2430
|
"done_signals": int(parts[4]),
|
|
2409
2431
|
})
|
|
2410
|
-
|
|
2411
|
-
|
|
2432
|
+
except Exception:
|
|
2433
|
+
continue
|
|
2412
2434
|
return {"dataPoints": data_points}
|
|
2413
2435
|
|
|
2414
2436
|
|
|
@@ -3002,7 +3024,7 @@ async def get_github_status(token: Optional[dict] = Depends(auth.get_current_tok
|
|
|
3002
3024
|
"pr_enabled": os.environ.get("LOKI_GITHUB_PR", "false") == "true",
|
|
3003
3025
|
"labels_filter": os.environ.get("LOKI_GITHUB_LABELS", ""),
|
|
3004
3026
|
"milestone_filter": os.environ.get("LOKI_GITHUB_MILESTONE", ""),
|
|
3005
|
-
"limit":
|
|
3027
|
+
"limit": _safe_int_env("LOKI_GITHUB_LIMIT", 100),
|
|
3006
3028
|
"imported_tasks": 0,
|
|
3007
3029
|
"synced_updates": 0,
|
|
3008
3030
|
"repo": None,
|
|
@@ -3232,7 +3254,7 @@ def _build_metrics_text() -> str:
|
|
|
3232
3254
|
lines.append("")
|
|
3233
3255
|
|
|
3234
3256
|
# 3. loki_iteration_max (gauge) -------------------------------------------
|
|
3235
|
-
max_iterations =
|
|
3257
|
+
max_iterations = _safe_int_env("LOKI_MAX_ITERATIONS", 1000)
|
|
3236
3258
|
lines.append("# HELP loki_iteration_max Maximum configured iterations")
|
|
3237
3259
|
lines.append("# TYPE loki_iteration_max gauge")
|
|
3238
3260
|
lines.append(f"loki_iteration_max {max_iterations}")
|
|
@@ -3402,6 +3424,132 @@ async def get_prd_observations():
|
|
|
3402
3424
|
return PlainTextResponse("Error reading PRD observations.", status_code=500)
|
|
3403
3425
|
|
|
3404
3426
|
|
|
3427
|
+
# =============================================================================
|
|
3428
|
+
# Checklist Waiver Management Endpoints (Phase 4)
|
|
3429
|
+
# =============================================================================
|
|
3430
|
+
|
|
3431
|
+
@app.get("/api/checklist/waivers")
|
|
3432
|
+
async def get_checklist_waivers():
|
|
3433
|
+
"""Get all checklist waivers."""
|
|
3434
|
+
waivers_file = _get_loki_dir() / "checklist" / "waivers.json"
|
|
3435
|
+
if not waivers_file.exists():
|
|
3436
|
+
return {"waivers": []}
|
|
3437
|
+
try:
|
|
3438
|
+
return json.loads(waivers_file.read_text())
|
|
3439
|
+
except (json.JSONDecodeError, IOError):
|
|
3440
|
+
return {"waivers": [], "error": "Failed to read waivers file"}
|
|
3441
|
+
|
|
3442
|
+
|
|
3443
|
+
@app.post("/api/checklist/waivers", dependencies=[Depends(auth.require_scope("control"))])
|
|
3444
|
+
async def add_checklist_waiver(request: Request):
|
|
3445
|
+
"""Add a waiver for a checklist item."""
|
|
3446
|
+
if not _control_limiter.check("control"):
|
|
3447
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
3448
|
+
try:
|
|
3449
|
+
body = await request.json()
|
|
3450
|
+
except Exception:
|
|
3451
|
+
return JSONResponse(status_code=400, content={"error": "Invalid JSON"})
|
|
3452
|
+
|
|
3453
|
+
item_id = body.get("item_id")
|
|
3454
|
+
reason = body.get("reason")
|
|
3455
|
+
if not item_id or not reason:
|
|
3456
|
+
return JSONResponse(status_code=400, content={"error": "item_id and reason required"})
|
|
3457
|
+
|
|
3458
|
+
if not isinstance(reason, str) or len(reason) > 1024:
|
|
3459
|
+
return JSONResponse(status_code=400, content={"error": "reason must be a string (max 1024 chars)"})
|
|
3460
|
+
|
|
3461
|
+
# Sanitize item_id: non-empty, max 256 chars, no path traversal
|
|
3462
|
+
if not isinstance(item_id, str) or len(item_id) > 256 or ".." in item_id or "/" in item_id or "\\" in item_id:
|
|
3463
|
+
return JSONResponse(status_code=400, content={"error": "Invalid item_id: must be 1-256 chars, no path traversal characters"})
|
|
3464
|
+
|
|
3465
|
+
waivers_file = _get_loki_dir() / "checklist" / "waivers.json"
|
|
3466
|
+
|
|
3467
|
+
# Load existing
|
|
3468
|
+
waivers = {"waivers": []}
|
|
3469
|
+
if waivers_file.exists():
|
|
3470
|
+
try:
|
|
3471
|
+
waivers = json.loads(waivers_file.read_text())
|
|
3472
|
+
except (json.JSONDecodeError, IOError):
|
|
3473
|
+
pass
|
|
3474
|
+
|
|
3475
|
+
# Check duplicate
|
|
3476
|
+
for w in waivers.get("waivers", []):
|
|
3477
|
+
if w.get("item_id") == item_id and w.get("active", True):
|
|
3478
|
+
return JSONResponse(status_code=409, content={"status": "already_exists", "item_id": item_id})
|
|
3479
|
+
|
|
3480
|
+
# Add waiver
|
|
3481
|
+
waiver = {
|
|
3482
|
+
"item_id": item_id,
|
|
3483
|
+
"reason": reason,
|
|
3484
|
+
"waived_by": body.get("waived_by", "dashboard"),
|
|
3485
|
+
"waived_at": datetime.now(timezone.utc).isoformat(),
|
|
3486
|
+
"active": True
|
|
3487
|
+
}
|
|
3488
|
+
waivers.setdefault("waivers", []).append(waiver)
|
|
3489
|
+
|
|
3490
|
+
# Ensure directory exists
|
|
3491
|
+
waivers_file.parent.mkdir(parents=True, exist_ok=True)
|
|
3492
|
+
|
|
3493
|
+
# Atomic write
|
|
3494
|
+
tmp_file = waivers_file.with_suffix(".tmp")
|
|
3495
|
+
tmp_file.write_text(json.dumps(waivers, indent=2))
|
|
3496
|
+
tmp_file.replace(waivers_file)
|
|
3497
|
+
|
|
3498
|
+
return {"status": "added", "waiver": waiver}
|
|
3499
|
+
|
|
3500
|
+
|
|
3501
|
+
@app.delete("/api/checklist/waivers/{item_id}", dependencies=[Depends(auth.require_scope("control"))])
|
|
3502
|
+
async def remove_checklist_waiver(item_id: str):
|
|
3503
|
+
"""Deactivate a waiver for a checklist item."""
|
|
3504
|
+
if not _control_limiter.check("control"):
|
|
3505
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
3506
|
+
|
|
3507
|
+
# Sanitize item_id: non-empty, max 256 chars, no path traversal
|
|
3508
|
+
if not item_id or len(item_id) > 256 or ".." in item_id or "/" in item_id or "\\" in item_id:
|
|
3509
|
+
raise HTTPException(status_code=400, detail="Invalid item_id: must be 1-256 chars, no path traversal characters")
|
|
3510
|
+
|
|
3511
|
+
waivers_file = _get_loki_dir() / "checklist" / "waivers.json"
|
|
3512
|
+
if not waivers_file.exists():
|
|
3513
|
+
return JSONResponse(status_code=404, content={"error": "No waivers file"})
|
|
3514
|
+
|
|
3515
|
+
try:
|
|
3516
|
+
waivers = json.loads(waivers_file.read_text())
|
|
3517
|
+
except (json.JSONDecodeError, IOError):
|
|
3518
|
+
return JSONResponse(status_code=500, content={"error": "Failed to read waivers"})
|
|
3519
|
+
|
|
3520
|
+
found = False
|
|
3521
|
+
for w in waivers.get("waivers", []):
|
|
3522
|
+
if w.get("item_id") == item_id and w.get("active", True):
|
|
3523
|
+
w["active"] = False
|
|
3524
|
+
found = True
|
|
3525
|
+
|
|
3526
|
+
if not found:
|
|
3527
|
+
return JSONResponse(status_code=404, content={"error": f"No active waiver for {item_id}"})
|
|
3528
|
+
|
|
3529
|
+
# Atomic write
|
|
3530
|
+
tmp_file = waivers_file.with_suffix(".tmp")
|
|
3531
|
+
tmp_file.write_text(json.dumps(waivers, indent=2))
|
|
3532
|
+
tmp_file.replace(waivers_file)
|
|
3533
|
+
|
|
3534
|
+
return {"status": "removed", "item_id": item_id}
|
|
3535
|
+
|
|
3536
|
+
|
|
3537
|
+
# =============================================================================
|
|
3538
|
+
# Council Hard Gate Endpoint (Phase 4)
|
|
3539
|
+
# =============================================================================
|
|
3540
|
+
|
|
3541
|
+
@app.get("/api/council/gate")
|
|
3542
|
+
async def get_council_gate():
|
|
3543
|
+
"""Get council hard gate status."""
|
|
3544
|
+
gate_file = _get_loki_dir() / "council" / "gate-block.json"
|
|
3545
|
+
if not gate_file.exists():
|
|
3546
|
+
return {"blocked": False}
|
|
3547
|
+
try:
|
|
3548
|
+
return json.loads(gate_file.read_text())
|
|
3549
|
+
except (json.JSONDecodeError, IOError):
|
|
3550
|
+
return {"blocked": False, "error": "Failed to read gate file"}
|
|
3551
|
+
|
|
3552
|
+
|
|
3405
3553
|
# =============================================================================
|
|
3406
3554
|
# App Runner Endpoints (v5.45.0)
|
|
3407
3555
|
# =============================================================================
|
|
@@ -3436,7 +3584,7 @@ async def get_app_runner_logs(lines: int = Query(default=100, ge=1, le=1000)):
|
|
|
3436
3584
|
@app.post("/api/control/app-restart", dependencies=[Depends(auth.require_scope("control"))])
|
|
3437
3585
|
async def control_app_restart(request: Request):
|
|
3438
3586
|
"""Signal app runner to restart the application."""
|
|
3439
|
-
if not _control_limiter.check(
|
|
3587
|
+
if not _control_limiter.check(request.client.host if request.client else "unknown"):
|
|
3440
3588
|
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
3441
3589
|
loki_dir = _get_loki_dir()
|
|
3442
3590
|
signal_dir = loki_dir / "app-runner"
|
|
@@ -3449,7 +3597,7 @@ async def control_app_restart(request: Request):
|
|
|
3449
3597
|
@app.post("/api/control/app-stop", dependencies=[Depends(auth.require_scope("control"))])
|
|
3450
3598
|
async def control_app_stop(request: Request):
|
|
3451
3599
|
"""Signal app runner to stop the application."""
|
|
3452
|
-
if not _control_limiter.check(
|
|
3600
|
+
if not _control_limiter.check(request.client.host if request.client else "unknown"):
|
|
3453
3601
|
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
3454
3602
|
loki_dir = _get_loki_dir()
|
|
3455
3603
|
signal_dir = loki_dir / "app-runner"
|
|
@@ -3487,7 +3635,7 @@ async def get_playwright_screenshot():
|
|
|
3487
3635
|
screenshots = sorted(screenshots_dir.glob("*.png"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
3488
3636
|
if not screenshots:
|
|
3489
3637
|
return {"screenshot": None}
|
|
3490
|
-
return
|
|
3638
|
+
return FileResponse(str(screenshots[0]), media_type="image/png")
|
|
3491
3639
|
|
|
3492
3640
|
|
|
3493
3641
|
# =============================================================================
|
|
@@ -3594,7 +3742,7 @@ def run_server(host: str = None, port: int = None) -> None:
|
|
|
3594
3742
|
# Default to localhost-only for security
|
|
3595
3743
|
host = os.environ.get("LOKI_DASHBOARD_HOST", "127.0.0.1")
|
|
3596
3744
|
if port is None:
|
|
3597
|
-
port =
|
|
3745
|
+
port = _safe_int_env("LOKI_DASHBOARD_PORT", 57374)
|
|
3598
3746
|
|
|
3599
3747
|
uvicorn_kwargs = {
|
|
3600
3748
|
"host": host,
|