loki-mode 6.60.0 → 6.62.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 +2 -2
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +34 -8
- package/autonomy/completion-council.sh +70 -32
- package/autonomy/issue-parser.sh +4 -7
- package/autonomy/loki +238 -119
- package/autonomy/notification-checker.py +49 -23
- package/autonomy/run.sh +162 -79
- package/autonomy/sandbox.sh +91 -24
- package/bin/loki-mode.js +1 -2
- package/bin/postinstall.js +10 -4
- package/dashboard/__init__.py +1 -1
- package/dashboard/control.py +46 -36
- package/dashboard/database.py +21 -4
- package/dashboard/server.py +107 -78
- package/docs/BUG-AUDIT-v6.61.0.md +957 -0
- package/docs/INSTALLATION.md +2 -2
- package/events/bus.py +129 -28
- package/events/bus.ts +41 -27
- package/events/emit.sh +1 -1
- package/integrations/openclaw/README.md +139 -0
- package/integrations/openclaw/SKILL.md +88 -0
- package/integrations/openclaw/bridge/__init__.py +1 -0
- package/integrations/openclaw/bridge/__main__.py +88 -0
- package/integrations/openclaw/bridge/schema_map.py +180 -0
- package/integrations/openclaw/bridge/watcher.py +100 -0
- package/integrations/openclaw/scripts/format-progress.sh +80 -0
- package/integrations/openclaw/scripts/poll-status.sh +74 -0
- package/integrations/vibe-kanban.md +289 -0
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +96 -73
- package/memory/consolidation.py +21 -6
- package/memory/engine.py +53 -26
- package/memory/layers/index_layer.py +16 -3
- package/memory/layers/timeline_layer.py +16 -3
- package/memory/retrieval.py +4 -1
- package/memory/schemas.py +4 -2
- package/memory/storage.py +25 -4
- package/memory/token_economics.py +9 -2
- package/memory/vector_index.py +2 -2
- package/package.json +3 -1
- package/providers/cline.sh +5 -4
- package/providers/codex.sh +27 -5
- package/providers/gemini.sh +59 -23
- package/providers/loader.sh +3 -2
- package/skills/parallel-workflows.md +9 -7
- package/state/__init__.py +10 -0
- package/state/index.ts +18 -0
- package/state/manager.py +1801 -0
- package/state/manager.ts +1774 -0
- package/state/sqlite_backend.py +188 -0
- package/state/test_manager.py +703 -0
- package/state/test_manager.ts +366 -0
- package/templates/README.md +19 -4
- package/templates/dashboard.md +45 -0
- package/templates/data-pipeline.md +45 -0
- package/templates/game.md +48 -0
- package/templates/microservice.md +49 -0
- package/templates/npm-library.md +42 -0
- package/templates/rest-api.md +170 -33
- package/templates/slack-bot.md +48 -0
- package/templates/web-scraper.md +45 -0
- package/web-app/server.py +360 -191
- package/templates/saas-app.md +0 -42
package/dashboard/server.py
CHANGED
|
@@ -16,7 +16,7 @@ from dataclasses import asdict
|
|
|
16
16
|
from contextlib import asynccontextmanager
|
|
17
17
|
from datetime import datetime, timedelta, timezone
|
|
18
18
|
from pathlib import Path as _Path
|
|
19
|
-
from typing import Any, Optional
|
|
19
|
+
from typing import Any, Literal, Optional
|
|
20
20
|
import re
|
|
21
21
|
|
|
22
22
|
from fastapi import (
|
|
@@ -30,7 +30,7 @@ from fastapi import (
|
|
|
30
30
|
)
|
|
31
31
|
from fastapi.middleware.cors import CORSMiddleware
|
|
32
32
|
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
33
|
-
from pydantic import BaseModel, Field, field_validator
|
|
33
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
34
34
|
from sqlalchemy import select, update, delete
|
|
35
35
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
36
36
|
from sqlalchemy.orm import selectinload
|
|
@@ -45,6 +45,7 @@ from .models import (
|
|
|
45
45
|
Task,
|
|
46
46
|
TaskPriority,
|
|
47
47
|
TaskStatus,
|
|
48
|
+
Tenant,
|
|
48
49
|
)
|
|
49
50
|
from . import registry
|
|
50
51
|
from . import auth
|
|
@@ -93,7 +94,7 @@ def _safe_json_read(path: _Path, default: Any = None) -> Any:
|
|
|
93
94
|
|
|
94
95
|
def _safe_read_text(path: _Path) -> str:
|
|
95
96
|
"""Read a text file with UTF-8 encoding, replacing non-UTF-8 bytes."""
|
|
96
|
-
return
|
|
97
|
+
return _Path(path).read_text(encoding="utf-8", errors="replace")
|
|
97
98
|
|
|
98
99
|
|
|
99
100
|
# ---------------------------------------------------------------------------
|
|
@@ -175,11 +176,13 @@ class ProjectUpdate(BaseModel):
|
|
|
175
176
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
|
176
177
|
description: Optional[str] = None
|
|
177
178
|
prd_path: Optional[str] = None
|
|
178
|
-
status: Optional[
|
|
179
|
+
status: Optional[Literal["active", "archived", "completed", "paused"]] = None
|
|
179
180
|
|
|
180
181
|
|
|
181
182
|
class ProjectResponse(BaseModel):
|
|
182
183
|
"""Schema for project response."""
|
|
184
|
+
model_config = ConfigDict(from_attributes=True)
|
|
185
|
+
|
|
183
186
|
id: int
|
|
184
187
|
name: str
|
|
185
188
|
description: Optional[str]
|
|
@@ -190,9 +193,6 @@ class ProjectResponse(BaseModel):
|
|
|
190
193
|
task_count: int = 0
|
|
191
194
|
completed_task_count: int = 0
|
|
192
195
|
|
|
193
|
-
class Config:
|
|
194
|
-
from_attributes = True
|
|
195
|
-
|
|
196
196
|
|
|
197
197
|
class TaskCreate(BaseModel):
|
|
198
198
|
"""Schema for creating a task."""
|
|
@@ -231,6 +231,8 @@ class TaskMove(BaseModel):
|
|
|
231
231
|
|
|
232
232
|
class TaskResponse(BaseModel):
|
|
233
233
|
"""Schema for task response."""
|
|
234
|
+
model_config = ConfigDict(from_attributes=True)
|
|
235
|
+
|
|
234
236
|
id: int
|
|
235
237
|
project_id: int
|
|
236
238
|
title: str
|
|
@@ -246,9 +248,6 @@ class TaskResponse(BaseModel):
|
|
|
246
248
|
updated_at: datetime
|
|
247
249
|
completed_at: Optional[datetime]
|
|
248
250
|
|
|
249
|
-
class Config:
|
|
250
|
-
from_attributes = True
|
|
251
|
-
|
|
252
251
|
|
|
253
252
|
class SessionInfo(BaseModel):
|
|
254
253
|
"""Info about a single running session."""
|
|
@@ -290,15 +289,16 @@ class ConnectionManager:
|
|
|
290
289
|
def __init__(self):
|
|
291
290
|
self.active_connections: list[WebSocket] = []
|
|
292
291
|
|
|
293
|
-
async def connect(self, websocket: WebSocket) ->
|
|
294
|
-
"""Accept a new WebSocket connection."""
|
|
292
|
+
async def connect(self, websocket: WebSocket) -> bool:
|
|
293
|
+
"""Accept a new WebSocket connection. Returns False if rejected."""
|
|
295
294
|
if len(self.active_connections) >= self.MAX_CONNECTIONS:
|
|
296
295
|
await websocket.accept()
|
|
297
296
|
await websocket.close(code=1013, reason="Connection limit reached. Try again later.")
|
|
298
297
|
logger.warning(f"WebSocket connection rejected: limit of {self.MAX_CONNECTIONS} reached")
|
|
299
|
-
return
|
|
298
|
+
return False
|
|
300
299
|
await websocket.accept()
|
|
301
300
|
self.active_connections.append(websocket)
|
|
301
|
+
return True
|
|
302
302
|
|
|
303
303
|
def disconnect(self, websocket: WebSocket) -> None:
|
|
304
304
|
"""Remove a WebSocket connection."""
|
|
@@ -416,7 +416,12 @@ async def _push_loki_state_loop() -> None:
|
|
|
416
416
|
async def lifespan(app: FastAPI):
|
|
417
417
|
"""Application lifespan handler."""
|
|
418
418
|
# Startup
|
|
419
|
-
|
|
419
|
+
try:
|
|
420
|
+
await init_db()
|
|
421
|
+
app.state.db_available = True
|
|
422
|
+
except Exception as exc:
|
|
423
|
+
logger.error("Database init failed: %s -- DB routes will return 503", exc)
|
|
424
|
+
app.state.db_available = False
|
|
420
425
|
_telemetry.send_telemetry("dashboard_start")
|
|
421
426
|
push_task = asyncio.create_task(_push_loki_state_loop())
|
|
422
427
|
yield
|
|
@@ -723,50 +728,54 @@ async def list_projects(
|
|
|
723
728
|
db: AsyncSession = Depends(get_db),
|
|
724
729
|
) -> list[ProjectResponse]:
|
|
725
730
|
"""List projects with pagination. Does not eager-load tasks for efficiency."""
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
731
|
+
try:
|
|
732
|
+
from sqlalchemy import func as sa_func
|
|
733
|
+
|
|
734
|
+
query = select(Project)
|
|
735
|
+
if status:
|
|
736
|
+
query = query.where(Project.status == status)
|
|
737
|
+
query = query.order_by(Project.created_at.desc()).offset(offset).limit(limit)
|
|
738
|
+
|
|
739
|
+
result = await db.execute(query)
|
|
740
|
+
projects = result.scalars().all()
|
|
741
|
+
|
|
742
|
+
# Batch-fetch task counts instead of N+1 eager loading
|
|
743
|
+
project_ids = [p.id for p in projects]
|
|
744
|
+
response = []
|
|
745
|
+
if project_ids:
|
|
746
|
+
count_query = (
|
|
747
|
+
select(
|
|
748
|
+
Task.project_id,
|
|
749
|
+
sa_func.count().label("total"),
|
|
750
|
+
sa_func.count().filter(Task.status == TaskStatus.DONE).label("done"),
|
|
751
|
+
)
|
|
752
|
+
.where(Task.project_id.in_(project_ids))
|
|
753
|
+
.group_by(Task.project_id)
|
|
745
754
|
)
|
|
746
|
-
.
|
|
747
|
-
.
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
task_count=total,
|
|
766
|
-
completed_task_count=done,
|
|
755
|
+
count_result = await db.execute(count_query)
|
|
756
|
+
counts = {row.project_id: (row.total, row.done) for row in count_result}
|
|
757
|
+
else:
|
|
758
|
+
counts = {}
|
|
759
|
+
|
|
760
|
+
for project in projects:
|
|
761
|
+
total, done = counts.get(project.id, (0, 0))
|
|
762
|
+
response.append(
|
|
763
|
+
ProjectResponse(
|
|
764
|
+
id=project.id,
|
|
765
|
+
name=project.name,
|
|
766
|
+
description=project.description,
|
|
767
|
+
prd_path=project.prd_path,
|
|
768
|
+
status=project.status,
|
|
769
|
+
created_at=project.created_at,
|
|
770
|
+
updated_at=project.updated_at,
|
|
771
|
+
task_count=total,
|
|
772
|
+
completed_task_count=done,
|
|
773
|
+
)
|
|
767
774
|
)
|
|
768
|
-
|
|
769
|
-
|
|
775
|
+
return response
|
|
776
|
+
except Exception as exc:
|
|
777
|
+
logger.error("Failed to list projects: %s", exc, exc_info=True)
|
|
778
|
+
raise HTTPException(status_code=500, detail="Database query failed") from exc
|
|
770
779
|
|
|
771
780
|
|
|
772
781
|
@app.post("/api/projects", response_model=ProjectResponse, status_code=201, dependencies=[Depends(auth.require_scope("control"))])
|
|
@@ -775,6 +784,13 @@ async def create_project(
|
|
|
775
784
|
db: AsyncSession = Depends(get_db),
|
|
776
785
|
) -> ProjectResponse:
|
|
777
786
|
"""Create a new project."""
|
|
787
|
+
# Validate tenant exists
|
|
788
|
+
tenant_result = await db.execute(
|
|
789
|
+
select(Tenant).where(Tenant.id == project.tenant_id)
|
|
790
|
+
)
|
|
791
|
+
if not tenant_result.scalar_one_or_none():
|
|
792
|
+
raise HTTPException(status_code=404, detail="Tenant not found")
|
|
793
|
+
|
|
778
794
|
db_project = Project(
|
|
779
795
|
name=project.name,
|
|
780
796
|
description=project.description,
|
|
@@ -1104,6 +1120,10 @@ async def list_tasks(
|
|
|
1104
1120
|
except Exception:
|
|
1105
1121
|
pass
|
|
1106
1122
|
|
|
1123
|
+
# Apply project_id filter if provided
|
|
1124
|
+
if project_id is not None:
|
|
1125
|
+
all_tasks = [t for t in all_tasks if t.get("project_id") == project_id]
|
|
1126
|
+
|
|
1107
1127
|
# Apply status filter if provided
|
|
1108
1128
|
if status:
|
|
1109
1129
|
all_tasks = [t for t in all_tasks if t["status"] == status]
|
|
@@ -1217,9 +1237,12 @@ async def update_task(
|
|
|
1217
1237
|
|
|
1218
1238
|
update_data = task_update.model_dump(exclude_unset=True)
|
|
1219
1239
|
|
|
1220
|
-
# Handle status change to completed
|
|
1221
|
-
if "status" in update_data
|
|
1222
|
-
update_data["
|
|
1240
|
+
# Handle status change to/from completed
|
|
1241
|
+
if "status" in update_data:
|
|
1242
|
+
if update_data["status"] == TaskStatus.DONE:
|
|
1243
|
+
update_data["completed_at"] = datetime.now(timezone.utc)
|
|
1244
|
+
else:
|
|
1245
|
+
update_data["completed_at"] = None
|
|
1223
1246
|
|
|
1224
1247
|
for field, value in update_data.items():
|
|
1225
1248
|
setattr(task, field, value)
|
|
@@ -1281,6 +1304,7 @@ _TASK_STATE_MACHINE: dict[TaskStatus, set[TaskStatus]] = {
|
|
|
1281
1304
|
TaskStatus.PENDING: {TaskStatus.IN_PROGRESS},
|
|
1282
1305
|
TaskStatus.IN_PROGRESS: {TaskStatus.REVIEW, TaskStatus.DONE},
|
|
1283
1306
|
TaskStatus.REVIEW: {TaskStatus.DONE, TaskStatus.IN_PROGRESS},
|
|
1307
|
+
TaskStatus.DONE: {TaskStatus.IN_PROGRESS, TaskStatus.REVIEW},
|
|
1284
1308
|
}
|
|
1285
1309
|
|
|
1286
1310
|
|
|
@@ -1360,12 +1384,14 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
|
1360
1384
|
import uuid as _uuid
|
|
1361
1385
|
client_ip = websocket.client.host if websocket.client else f"ws-{_uuid.uuid4().hex}"
|
|
1362
1386
|
if not _read_limiter.check(f"ws_{client_ip}"):
|
|
1387
|
+
await websocket.accept()
|
|
1363
1388
|
await websocket.close(code=1008) # Policy Violation
|
|
1364
1389
|
return
|
|
1365
1390
|
|
|
1366
1391
|
if auth.is_enterprise_mode() or auth.is_oidc_mode():
|
|
1367
1392
|
ws_token: Optional[str] = websocket.query_params.get("token")
|
|
1368
1393
|
if not ws_token:
|
|
1394
|
+
await websocket.accept()
|
|
1369
1395
|
await websocket.close(code=1008) # Policy Violation
|
|
1370
1396
|
return
|
|
1371
1397
|
|
|
@@ -1378,10 +1404,13 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
|
1378
1404
|
token_info = auth.validate_token(ws_token)
|
|
1379
1405
|
|
|
1380
1406
|
if token_info is None:
|
|
1407
|
+
await websocket.accept()
|
|
1381
1408
|
await websocket.close(code=1008) # Policy Violation
|
|
1382
1409
|
return
|
|
1383
1410
|
|
|
1384
|
-
await manager.connect(websocket)
|
|
1411
|
+
connected = await manager.connect(websocket)
|
|
1412
|
+
if not connected:
|
|
1413
|
+
return
|
|
1385
1414
|
try:
|
|
1386
1415
|
# Send initial connection confirmation
|
|
1387
1416
|
await manager.send_personal(websocket, {
|
|
@@ -1424,9 +1453,10 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
|
1424
1453
|
break
|
|
1425
1454
|
|
|
1426
1455
|
except WebSocketDisconnect:
|
|
1427
|
-
|
|
1456
|
+
pass
|
|
1428
1457
|
except Exception as e:
|
|
1429
1458
|
logger.error(f"WebSocket error: {e}")
|
|
1459
|
+
finally:
|
|
1430
1460
|
manager.disconnect(websocket)
|
|
1431
1461
|
|
|
1432
1462
|
|
|
@@ -1632,7 +1662,7 @@ class TokenResponse(BaseModel):
|
|
|
1632
1662
|
token: Optional[str] = None # Only on creation
|
|
1633
1663
|
|
|
1634
1664
|
|
|
1635
|
-
@app.post("/api/enterprise/tokens", response_model=TokenResponse, status_code=201)
|
|
1665
|
+
@app.post("/api/enterprise/tokens", response_model=TokenResponse, status_code=201, dependencies=[Depends(auth.require_scope("admin"))])
|
|
1636
1666
|
async def create_token(request: TokenCreateRequest):
|
|
1637
1667
|
"""
|
|
1638
1668
|
Generate a new API token (enterprise only).
|
|
@@ -1737,7 +1767,7 @@ async def query_audit_logs(
|
|
|
1737
1767
|
resource_type: Optional[str] = None,
|
|
1738
1768
|
resource_id: Optional[str] = None,
|
|
1739
1769
|
limit: int = Query(default=100, ge=1, le=1000),
|
|
1740
|
-
offset: int = 0,
|
|
1770
|
+
offset: int = Query(default=0, ge=0),
|
|
1741
1771
|
):
|
|
1742
1772
|
"""
|
|
1743
1773
|
Query audit logs (enterprise only).
|
|
@@ -2133,7 +2163,7 @@ def _get_memory_storage():
|
|
|
2133
2163
|
@app.get("/api/memory/search")
|
|
2134
2164
|
async def search_memory(
|
|
2135
2165
|
q: str = Query(..., min_length=1, max_length=500, description="Search query"),
|
|
2136
|
-
collection: str = Query(default="all",
|
|
2166
|
+
collection: str = Query(default="all", pattern="^(episodes|patterns|skills|all)$"),
|
|
2137
2167
|
limit: int = Query(default=20, ge=1, le=100),
|
|
2138
2168
|
):
|
|
2139
2169
|
"""Full-text search across memory using FTS5."""
|
|
@@ -2342,7 +2372,7 @@ async def get_learning_signals(
|
|
|
2342
2372
|
signalType: Optional[str] = None,
|
|
2343
2373
|
source: Optional[str] = None,
|
|
2344
2374
|
limit: int = Query(default=50, ge=1, le=1000),
|
|
2345
|
-
offset: int = 0,
|
|
2375
|
+
offset: int = Query(default=0, ge=0),
|
|
2346
2376
|
):
|
|
2347
2377
|
"""Get raw learning signals from both events.jsonl and learning signals directory."""
|
|
2348
2378
|
events = _read_events(timeRange)
|
|
@@ -2688,19 +2718,18 @@ async def pause_session():
|
|
|
2688
2718
|
content={"success": False, "message": "Session process is not running; pause signal may have no effect"},
|
|
2689
2719
|
)
|
|
2690
2720
|
|
|
2691
|
-
#
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
)
|
|
2721
|
+
# Verify process is still alive after writing the PAUSE file.
|
|
2722
|
+
# Give the process a moment to notice the signal, then confirm it
|
|
2723
|
+
# has not exited unexpectedly.
|
|
2724
|
+
await asyncio.sleep(0.5)
|
|
2725
|
+
try:
|
|
2726
|
+
os.kill(pid, 0)
|
|
2727
|
+
return {"success": True, "message": "Session paused", "process_verified": True}
|
|
2728
|
+
except OSError:
|
|
2729
|
+
return JSONResponse(
|
|
2730
|
+
status_code=503,
|
|
2731
|
+
content={"success": False, "message": "Session process exited unexpectedly after pause signal"},
|
|
2732
|
+
)
|
|
2704
2733
|
|
|
2705
2734
|
|
|
2706
2735
|
@app.post("/api/control/resume", dependencies=[Depends(auth.require_scope("control"))])
|