loki-mode 6.27.0 → 6.27.2
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 +2 -2
- package/VERSION +1 -1
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/package.json +3 -2
- package/templates/rest-api.md +43 -0
- package/templates/saas-app.md +42 -0
- package/web-app/server.py +581 -0
package/README.md
CHANGED
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
[](https://www.autonomi.dev/)
|
|
11
11
|
[](https://hub.docker.com/r/asklokesh/loki-mode)
|
|
12
12
|
|
|
13
|
-
**Current Version: v6.
|
|
13
|
+
**Current Version: v6.27.0**
|
|
14
14
|
|
|
15
15
|
### Traction
|
|
16
16
|
|
|
17
|
-
**737 stars** | **150 forks** | **10,
|
|
17
|
+
**737 stars** | **150 forks** | **10,600+ Docker pulls** | **19,000+ npm downloads** | **590 commits** | **252 releases published** | **18 releases in a single day (March 18, 2026)**
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
6.27.
|
|
1
|
+
6.27.2
|
package/dashboard/__init__.py
CHANGED
package/docs/INSTALLATION.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loki-mode",
|
|
3
|
-
"version": "6.27.
|
|
3
|
+
"version": "6.27.2",
|
|
4
4
|
"description": "Loki Mode by Autonomi - Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -82,7 +82,8 @@
|
|
|
82
82
|
"mcp/",
|
|
83
83
|
"completions/",
|
|
84
84
|
"src/",
|
|
85
|
-
"web-app/dist/"
|
|
85
|
+
"web-app/dist/",
|
|
86
|
+
"web-app/server.py"
|
|
86
87
|
],
|
|
87
88
|
"scripts": {
|
|
88
89
|
"postinstall": "node bin/postinstall.js",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# PRD: REST API Service
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
A production-ready RESTful API backend with authentication, CRUD operations, pagination, filtering, rate limiting, and comprehensive documentation.
|
|
5
|
+
|
|
6
|
+
## Target Users
|
|
7
|
+
- Backend developers building API-first applications
|
|
8
|
+
- Teams needing a structured API for frontend or mobile clients
|
|
9
|
+
- Developers learning REST API best practices
|
|
10
|
+
|
|
11
|
+
## Core Features
|
|
12
|
+
1. **Authentication** - JWT-based auth with access and refresh tokens, password hashing with bcrypt
|
|
13
|
+
2. **Resource CRUD** - Full create, read, update, delete operations with proper HTTP methods and status codes
|
|
14
|
+
3. **Pagination and Filtering** - Cursor-based pagination, field filtering, sorting, and search across resources
|
|
15
|
+
4. **Rate Limiting** - Per-endpoint and per-user rate limits with configurable windows and limits
|
|
16
|
+
5. **Input Validation** - Request body and query parameter validation with detailed error messages
|
|
17
|
+
6. **API Documentation** - Auto-generated OpenAPI/Swagger documentation with interactive testing
|
|
18
|
+
7. **Error Handling** - Consistent error response format with appropriate HTTP status codes
|
|
19
|
+
|
|
20
|
+
## Technical Requirements
|
|
21
|
+
- Node.js with Express and TypeScript
|
|
22
|
+
- Prisma ORM with SQLite (dev) / PostgreSQL (prod)
|
|
23
|
+
- JSON Web Tokens for stateless authentication
|
|
24
|
+
- Express middleware architecture
|
|
25
|
+
- Environment-based configuration
|
|
26
|
+
- Structured logging with request correlation IDs
|
|
27
|
+
- Database migrations and seed data
|
|
28
|
+
|
|
29
|
+
## Quality Gates
|
|
30
|
+
- Unit tests for middleware, validators, and business logic
|
|
31
|
+
- Integration tests for all API endpoints
|
|
32
|
+
- Authentication flow tested end-to-end
|
|
33
|
+
- Rate limiter tested under concurrent requests
|
|
34
|
+
- OpenAPI spec validates against schema
|
|
35
|
+
- No N+1 query issues in list endpoints
|
|
36
|
+
|
|
37
|
+
## Success Metrics
|
|
38
|
+
- All CRUD endpoints return correct status codes and response shapes
|
|
39
|
+
- JWT auth flow works: register, login, refresh, logout
|
|
40
|
+
- Pagination returns correct pages with proper metadata
|
|
41
|
+
- Rate limiter blocks excessive requests with 429 responses
|
|
42
|
+
- Swagger UI serves interactive documentation
|
|
43
|
+
- All tests pass
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# PRD: SaaS Application
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
A modern SaaS web application with user authentication, subscription billing, team management, and an admin dashboard.
|
|
5
|
+
|
|
6
|
+
## Target Users
|
|
7
|
+
- Indie developers launching subscription-based products
|
|
8
|
+
- Small teams needing a billing-ready web application
|
|
9
|
+
- Founders validating a SaaS idea quickly
|
|
10
|
+
|
|
11
|
+
## Core Features
|
|
12
|
+
1. **User Authentication** - Email/password signup and login with session management and email verification
|
|
13
|
+
2. **OAuth Integration** - Sign in with Google and GitHub for frictionless onboarding
|
|
14
|
+
3. **Subscription Billing** - Stripe integration with Free, Pro, and Enterprise pricing tiers
|
|
15
|
+
4. **Admin Dashboard** - User management, subscription metrics, revenue analytics, and system health monitoring
|
|
16
|
+
5. **User Settings** - Profile editing, password changes, avatar upload, and plan management
|
|
17
|
+
6. **Team Management** - Invite members by email, assign roles (owner, admin, member), manage permissions
|
|
18
|
+
7. **API Layer** - RESTful API with authentication middleware, rate limiting, and input validation
|
|
19
|
+
|
|
20
|
+
## Technical Requirements
|
|
21
|
+
- Next.js 14 with App Router and TypeScript
|
|
22
|
+
- TailwindCSS with shadcn/ui components
|
|
23
|
+
- Prisma ORM with PostgreSQL
|
|
24
|
+
- NextAuth.js v5 for authentication
|
|
25
|
+
- Stripe SDK for payment processing
|
|
26
|
+
- Server-side rendering for authenticated pages
|
|
27
|
+
- Middleware-based route protection
|
|
28
|
+
|
|
29
|
+
## Quality Gates
|
|
30
|
+
- Unit tests for business logic and utilities (Vitest)
|
|
31
|
+
- API integration tests for auth flow and billing webhooks
|
|
32
|
+
- E2E tests for signup, subscribe, and cancellation flow (Playwright)
|
|
33
|
+
- All API routes validated with zod schemas
|
|
34
|
+
- Stripe webhook signature verification
|
|
35
|
+
- CSRF protection on all mutations
|
|
36
|
+
|
|
37
|
+
## Success Metrics
|
|
38
|
+
- User can sign up, verify email, and log in via email or OAuth
|
|
39
|
+
- Subscription checkout and cancellation work end-to-end
|
|
40
|
+
- Admin dashboard displays accurate user and revenue data
|
|
41
|
+
- Role-based access control enforced on all routes
|
|
42
|
+
- All tests pass with zero console errors
|
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"""Purple Lab - Standalone product backend for Loki Mode.
|
|
2
|
+
|
|
3
|
+
A Replit-like web UI where users input PRDs and watch agents work.
|
|
4
|
+
Separate from the dashboard (which monitors existing sessions).
|
|
5
|
+
Purple Lab IS the product -- it starts and manages loki sessions.
|
|
6
|
+
|
|
7
|
+
Runs on port 57375 (dashboard uses 57374).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import inspect
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
23
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
24
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
25
|
+
from fastapi.staticfiles import StaticFiles
|
|
26
|
+
from pydantic import BaseModel
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Configuration
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
HOST = os.environ.get("PURPLE_LAB_HOST", "127.0.0.1")
|
|
33
|
+
PORT = int(os.environ.get("PURPLE_LAB_PORT", "57375"))
|
|
34
|
+
|
|
35
|
+
# Resolve paths
|
|
36
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
37
|
+
PROJECT_ROOT = SCRIPT_DIR.parent
|
|
38
|
+
LOKI_CLI = PROJECT_ROOT / "autonomy" / "loki"
|
|
39
|
+
DIST_DIR = SCRIPT_DIR / "dist"
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# App setup
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
app = FastAPI(title="Purple Lab", docs_url=None, redoc_url=None)
|
|
46
|
+
|
|
47
|
+
app.add_middleware(
|
|
48
|
+
CORSMiddleware,
|
|
49
|
+
allow_origins=["http://127.0.0.1:57375", "http://localhost:57375"],
|
|
50
|
+
allow_methods=["*"],
|
|
51
|
+
allow_headers=["*"],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Session state
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SessionState:
|
|
60
|
+
"""Tracks the active loki session."""
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
self.process: Optional[subprocess.Popen] = None
|
|
64
|
+
self.running = False
|
|
65
|
+
self.provider = ""
|
|
66
|
+
self.prd_text = ""
|
|
67
|
+
self.project_dir = ""
|
|
68
|
+
self.start_time: float = 0
|
|
69
|
+
self.log_lines: list[str] = []
|
|
70
|
+
self.ws_clients: set[WebSocket] = set()
|
|
71
|
+
self._reader_task: Optional[asyncio.Task] = None
|
|
72
|
+
|
|
73
|
+
def reset(self) -> None:
|
|
74
|
+
self.process = None
|
|
75
|
+
self.running = False
|
|
76
|
+
self.provider = ""
|
|
77
|
+
self.prd_text = ""
|
|
78
|
+
self.project_dir = ""
|
|
79
|
+
self.start_time = 0
|
|
80
|
+
self.log_lines = []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
session = SessionState()
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Request / Response models
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class StartRequest(BaseModel):
|
|
91
|
+
prd: str
|
|
92
|
+
provider: str = "claude"
|
|
93
|
+
projectDir: Optional[str] = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class StopResponse(BaseModel):
|
|
97
|
+
stopped: bool
|
|
98
|
+
message: str
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Helpers
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _loki_dir() -> Path:
|
|
106
|
+
"""Return the .loki/ directory for the current session project."""
|
|
107
|
+
if session.project_dir:
|
|
108
|
+
return Path(session.project_dir) / ".loki"
|
|
109
|
+
return Path.home() / ".loki"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _safe_resolve(base: Path, requested: str) -> Optional[Path]:
|
|
113
|
+
"""Resolve a path ensuring it stays within base (path traversal protection)."""
|
|
114
|
+
try:
|
|
115
|
+
resolved = (base / requested).resolve()
|
|
116
|
+
base_resolved = base.resolve()
|
|
117
|
+
if str(resolved).startswith(str(base_resolved)):
|
|
118
|
+
return resolved
|
|
119
|
+
except (ValueError, OSError):
|
|
120
|
+
pass
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def _broadcast(msg: dict) -> None:
|
|
125
|
+
"""Send a JSON message to all connected WebSocket clients."""
|
|
126
|
+
data = json.dumps(msg)
|
|
127
|
+
dead: list[WebSocket] = []
|
|
128
|
+
for ws in session.ws_clients:
|
|
129
|
+
try:
|
|
130
|
+
await ws.send_text(data)
|
|
131
|
+
except Exception:
|
|
132
|
+
dead.append(ws)
|
|
133
|
+
for ws in dead:
|
|
134
|
+
session.ws_clients.discard(ws)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def _read_process_output() -> None:
|
|
138
|
+
"""Background task: read loki stdout/stderr and broadcast lines."""
|
|
139
|
+
proc = session.process
|
|
140
|
+
if proc is None or proc.stdout is None:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
loop = asyncio.get_event_loop()
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
while session.running and proc.poll() is None:
|
|
147
|
+
line = await loop.run_in_executor(None, proc.stdout.readline)
|
|
148
|
+
if not line:
|
|
149
|
+
break
|
|
150
|
+
text = line.rstrip("\n")
|
|
151
|
+
session.log_lines.append(text)
|
|
152
|
+
# Keep last 5000 lines
|
|
153
|
+
if len(session.log_lines) > 5000:
|
|
154
|
+
session.log_lines = session.log_lines[-5000:]
|
|
155
|
+
await _broadcast({
|
|
156
|
+
"type": "log",
|
|
157
|
+
"data": {"line": text, "timestamp": time.strftime("%H:%M:%S")},
|
|
158
|
+
})
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
finally:
|
|
162
|
+
# Process ended
|
|
163
|
+
session.running = False
|
|
164
|
+
await _broadcast({"type": "session_end", "data": {"message": "Session ended"}})
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _build_file_tree(root: Path, max_depth: int = 4, _depth: int = 0) -> list[dict]:
|
|
168
|
+
"""Recursively build a file tree from a directory."""
|
|
169
|
+
if _depth >= max_depth or not root.is_dir():
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
entries = []
|
|
173
|
+
try:
|
|
174
|
+
items = sorted(root.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
175
|
+
except PermissionError:
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
for item in items:
|
|
179
|
+
# Skip hidden dirs and common noise
|
|
180
|
+
if item.name.startswith(".") and item.name not in (".loki",):
|
|
181
|
+
continue
|
|
182
|
+
if item.name in ("node_modules", "__pycache__", ".git", "venv", ".venv"):
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
node: dict = {"name": item.name, "path": str(item.relative_to(root))}
|
|
186
|
+
if item.is_dir():
|
|
187
|
+
node["type"] = "directory"
|
|
188
|
+
node["children"] = _build_file_tree(item, max_depth, _depth + 1)
|
|
189
|
+
else:
|
|
190
|
+
node["type"] = "file"
|
|
191
|
+
try:
|
|
192
|
+
node["size"] = item.stat().st_size
|
|
193
|
+
except OSError:
|
|
194
|
+
node["size"] = 0
|
|
195
|
+
entries.append(node)
|
|
196
|
+
return entries
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# API endpoints
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.post("/api/session/start")
|
|
204
|
+
async def start_session(req: StartRequest) -> JSONResponse:
|
|
205
|
+
"""Start a new loki session with the given PRD."""
|
|
206
|
+
if session.running:
|
|
207
|
+
return JSONResponse(
|
|
208
|
+
status_code=409,
|
|
209
|
+
content={"error": "A session is already running. Stop it first."},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Determine project directory
|
|
213
|
+
project_dir = req.projectDir
|
|
214
|
+
if not project_dir:
|
|
215
|
+
project_dir = os.path.join(Path.home(), "purple-lab-projects", f"project-{int(time.time())}")
|
|
216
|
+
os.makedirs(project_dir, exist_ok=True)
|
|
217
|
+
|
|
218
|
+
# Write PRD to a temp file in the project dir
|
|
219
|
+
prd_path = os.path.join(project_dir, "PRD.md")
|
|
220
|
+
with open(prd_path, "w") as f:
|
|
221
|
+
f.write(req.prd)
|
|
222
|
+
|
|
223
|
+
# Build the loki start command
|
|
224
|
+
cmd = [
|
|
225
|
+
str(LOKI_CLI),
|
|
226
|
+
"start",
|
|
227
|
+
"--provider", req.provider,
|
|
228
|
+
prd_path,
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
proc = subprocess.Popen(
|
|
233
|
+
cmd,
|
|
234
|
+
stdout=subprocess.PIPE,
|
|
235
|
+
stderr=subprocess.STDOUT,
|
|
236
|
+
stdin=subprocess.DEVNULL,
|
|
237
|
+
text=True,
|
|
238
|
+
cwd=project_dir,
|
|
239
|
+
env={**os.environ, "LOKI_DIR": os.path.join(project_dir, ".loki")},
|
|
240
|
+
)
|
|
241
|
+
except FileNotFoundError:
|
|
242
|
+
return JSONResponse(
|
|
243
|
+
status_code=500,
|
|
244
|
+
content={"error": f"loki CLI not found at {LOKI_CLI}"},
|
|
245
|
+
)
|
|
246
|
+
except Exception as e:
|
|
247
|
+
return JSONResponse(
|
|
248
|
+
status_code=500,
|
|
249
|
+
content={"error": f"Failed to start session: {e}"},
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Update session state
|
|
253
|
+
session.reset()
|
|
254
|
+
session.process = proc
|
|
255
|
+
session.running = True
|
|
256
|
+
session.provider = req.provider
|
|
257
|
+
session.prd_text = req.prd
|
|
258
|
+
session.project_dir = project_dir
|
|
259
|
+
session.start_time = time.time()
|
|
260
|
+
|
|
261
|
+
# Start background output reader
|
|
262
|
+
session._reader_task = asyncio.create_task(_read_process_output())
|
|
263
|
+
|
|
264
|
+
await _broadcast({"type": "session_start", "data": {
|
|
265
|
+
"provider": req.provider,
|
|
266
|
+
"projectDir": project_dir,
|
|
267
|
+
"pid": proc.pid,
|
|
268
|
+
}})
|
|
269
|
+
|
|
270
|
+
return JSONResponse(content={
|
|
271
|
+
"started": True,
|
|
272
|
+
"pid": proc.pid,
|
|
273
|
+
"projectDir": project_dir,
|
|
274
|
+
"provider": req.provider,
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@app.post("/api/session/stop")
|
|
279
|
+
async def stop_session() -> JSONResponse:
|
|
280
|
+
"""Stop the current loki session."""
|
|
281
|
+
if not session.running or session.process is None:
|
|
282
|
+
return JSONResponse(content={"stopped": False, "message": "No session running"})
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
# Send SIGTERM, then SIGKILL after 5s
|
|
286
|
+
session.process.terminate()
|
|
287
|
+
try:
|
|
288
|
+
session.process.wait(timeout=5)
|
|
289
|
+
except subprocess.TimeoutExpired:
|
|
290
|
+
session.process.kill()
|
|
291
|
+
session.process.wait(timeout=3)
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
session.running = False
|
|
296
|
+
await _broadcast({"type": "session_end", "data": {"message": "Session stopped by user"}})
|
|
297
|
+
|
|
298
|
+
return JSONResponse(content={"stopped": True, "message": "Session stopped"})
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@app.get("/api/session/status")
|
|
302
|
+
async def get_status() -> JSONResponse:
|
|
303
|
+
"""Get current session status."""
|
|
304
|
+
# Check if process is still alive
|
|
305
|
+
if session.process and session.running:
|
|
306
|
+
if session.process.poll() is not None:
|
|
307
|
+
session.running = False
|
|
308
|
+
|
|
309
|
+
# Try to read .loki state files for richer status
|
|
310
|
+
loki_dir = _loki_dir()
|
|
311
|
+
phase = "idle"
|
|
312
|
+
iteration = 0
|
|
313
|
+
complexity = "standard"
|
|
314
|
+
current_task = ""
|
|
315
|
+
pending_tasks = 0
|
|
316
|
+
|
|
317
|
+
state_file = loki_dir / "state" / "session.json"
|
|
318
|
+
if state_file.exists():
|
|
319
|
+
try:
|
|
320
|
+
with open(state_file) as f:
|
|
321
|
+
state = json.load(f)
|
|
322
|
+
phase = state.get("phase", phase)
|
|
323
|
+
iteration = state.get("iteration", iteration)
|
|
324
|
+
complexity = state.get("complexity", complexity)
|
|
325
|
+
current_task = state.get("current_task", current_task)
|
|
326
|
+
pending_tasks = state.get("pending_tasks", pending_tasks)
|
|
327
|
+
except (json.JSONDecodeError, OSError):
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
uptime = time.time() - session.start_time if session.running else 0
|
|
331
|
+
|
|
332
|
+
return JSONResponse(content={
|
|
333
|
+
"running": session.running,
|
|
334
|
+
"paused": False,
|
|
335
|
+
"phase": phase,
|
|
336
|
+
"iteration": iteration,
|
|
337
|
+
"complexity": complexity,
|
|
338
|
+
"mode": "autonomous",
|
|
339
|
+
"provider": session.provider,
|
|
340
|
+
"current_task": current_task,
|
|
341
|
+
"pending_tasks": pending_tasks,
|
|
342
|
+
"running_agents": 0,
|
|
343
|
+
"uptime": round(uptime),
|
|
344
|
+
"version": "",
|
|
345
|
+
"pid": str(session.process.pid) if session.process else "",
|
|
346
|
+
"projectDir": session.project_dir,
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@app.get("/api/session/logs")
|
|
351
|
+
async def get_logs(lines: int = 200) -> JSONResponse:
|
|
352
|
+
"""Get recent log lines."""
|
|
353
|
+
recent = session.log_lines[-lines:] if session.log_lines else []
|
|
354
|
+
entries = []
|
|
355
|
+
for line in recent:
|
|
356
|
+
level = "info"
|
|
357
|
+
lower = line.lower()
|
|
358
|
+
if "error" in lower or "fail" in lower:
|
|
359
|
+
level = "error"
|
|
360
|
+
elif "warn" in lower:
|
|
361
|
+
level = "warning"
|
|
362
|
+
elif "debug" in lower:
|
|
363
|
+
level = "debug"
|
|
364
|
+
entries.append({
|
|
365
|
+
"timestamp": "",
|
|
366
|
+
"level": level,
|
|
367
|
+
"message": line,
|
|
368
|
+
"source": "loki",
|
|
369
|
+
})
|
|
370
|
+
return JSONResponse(content=entries)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@app.get("/api/session/agents")
|
|
374
|
+
async def get_agents() -> JSONResponse:
|
|
375
|
+
"""Get agent status from .loki state."""
|
|
376
|
+
loki_dir = _loki_dir()
|
|
377
|
+
agents_file = loki_dir / "state" / "agents.json"
|
|
378
|
+
if agents_file.exists():
|
|
379
|
+
try:
|
|
380
|
+
with open(agents_file) as f:
|
|
381
|
+
agents = json.load(f)
|
|
382
|
+
if isinstance(agents, list):
|
|
383
|
+
return JSONResponse(content=agents)
|
|
384
|
+
except (json.JSONDecodeError, OSError):
|
|
385
|
+
pass
|
|
386
|
+
return JSONResponse(content=[])
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@app.get("/api/session/files")
|
|
390
|
+
async def get_files() -> JSONResponse:
|
|
391
|
+
"""Get the project file tree."""
|
|
392
|
+
if not session.project_dir:
|
|
393
|
+
return JSONResponse(content=[])
|
|
394
|
+
|
|
395
|
+
root = Path(session.project_dir)
|
|
396
|
+
if not root.is_dir():
|
|
397
|
+
return JSONResponse(content=[])
|
|
398
|
+
|
|
399
|
+
tree = _build_file_tree(root)
|
|
400
|
+
return JSONResponse(content=tree)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@app.get("/api/session/files/content")
|
|
404
|
+
async def get_file_content(path: str = "") -> JSONResponse:
|
|
405
|
+
"""Get file content with path traversal protection."""
|
|
406
|
+
if not session.project_dir or not path:
|
|
407
|
+
return JSONResponse(status_code=400, content={"error": "No active session or path"})
|
|
408
|
+
|
|
409
|
+
base = Path(session.project_dir).resolve()
|
|
410
|
+
resolved = _safe_resolve(base, path)
|
|
411
|
+
if resolved is None or not resolved.is_file():
|
|
412
|
+
return JSONResponse(status_code=404, content={"error": "File not found"})
|
|
413
|
+
|
|
414
|
+
# Limit file size to 1MB
|
|
415
|
+
try:
|
|
416
|
+
size = resolved.stat().st_size
|
|
417
|
+
if size > 1_048_576:
|
|
418
|
+
return JSONResponse(content={"content": f"[File too large: {size} bytes]"})
|
|
419
|
+
content = resolved.read_text(errors="replace")
|
|
420
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
421
|
+
return JSONResponse(content={"content": f"[Cannot read file: {e}]"})
|
|
422
|
+
|
|
423
|
+
return JSONResponse(content={"content": content})
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@app.get("/api/session/memory")
|
|
427
|
+
async def get_memory() -> JSONResponse:
|
|
428
|
+
"""Get memory summary from .loki state."""
|
|
429
|
+
loki_dir = _loki_dir()
|
|
430
|
+
memory_dir = loki_dir / "memory"
|
|
431
|
+
if not memory_dir.is_dir():
|
|
432
|
+
return JSONResponse(content={
|
|
433
|
+
"episodic_count": 0,
|
|
434
|
+
"semantic_count": 0,
|
|
435
|
+
"skill_count": 0,
|
|
436
|
+
"total_tokens": 0,
|
|
437
|
+
"last_consolidation": None,
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
episodic = len(list((memory_dir / "episodic").glob("*.json"))) if (memory_dir / "episodic").is_dir() else 0
|
|
441
|
+
semantic = len(list((memory_dir / "semantic").glob("*.json"))) if (memory_dir / "semantic").is_dir() else 0
|
|
442
|
+
skills = len(list((memory_dir / "skills").glob("*.json"))) if (memory_dir / "skills").is_dir() else 0
|
|
443
|
+
|
|
444
|
+
return JSONResponse(content={
|
|
445
|
+
"episodic_count": episodic,
|
|
446
|
+
"semantic_count": semantic,
|
|
447
|
+
"skill_count": skills,
|
|
448
|
+
"total_tokens": 0,
|
|
449
|
+
"last_consolidation": None,
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@app.get("/api/session/checklist")
|
|
454
|
+
async def get_checklist() -> JSONResponse:
|
|
455
|
+
"""Get quality gates checklist from .loki state."""
|
|
456
|
+
loki_dir = _loki_dir()
|
|
457
|
+
checklist_file = loki_dir / "state" / "checklist.json"
|
|
458
|
+
if checklist_file.exists():
|
|
459
|
+
try:
|
|
460
|
+
with open(checklist_file) as f:
|
|
461
|
+
data = json.load(f)
|
|
462
|
+
return JSONResponse(content=data)
|
|
463
|
+
except (json.JSONDecodeError, OSError):
|
|
464
|
+
pass
|
|
465
|
+
return JSONResponse(content={
|
|
466
|
+
"total": 0, "passed": 0, "failed": 0, "skipped": 0, "pending": 0, "items": [],
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@app.get("/api/templates")
|
|
471
|
+
async def get_templates() -> JSONResponse:
|
|
472
|
+
"""List available PRD templates."""
|
|
473
|
+
templates_dir = PROJECT_ROOT / "templates"
|
|
474
|
+
if not templates_dir.is_dir():
|
|
475
|
+
return JSONResponse(content=[])
|
|
476
|
+
|
|
477
|
+
templates = []
|
|
478
|
+
for f in sorted(templates_dir.glob("*.md")):
|
|
479
|
+
name = f.stem.replace("-", " ").replace("_", " ").title()
|
|
480
|
+
templates.append({"name": name, "filename": f.name})
|
|
481
|
+
return JSONResponse(content=templates)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@app.get("/api/templates/{filename}")
|
|
485
|
+
async def get_template_content(filename: str) -> JSONResponse:
|
|
486
|
+
"""Get a specific template's content."""
|
|
487
|
+
templates_dir = PROJECT_ROOT / "templates"
|
|
488
|
+
resolved = _safe_resolve(templates_dir, filename)
|
|
489
|
+
if resolved is None or not resolved.is_file():
|
|
490
|
+
return JSONResponse(status_code=404, content={"error": "Template not found"})
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
content = resolved.read_text()
|
|
494
|
+
except OSError:
|
|
495
|
+
return JSONResponse(status_code=500, content={"error": "Cannot read template"})
|
|
496
|
+
|
|
497
|
+
return JSONResponse(content={"name": filename, "content": content})
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# ---------------------------------------------------------------------------
|
|
501
|
+
# WebSocket
|
|
502
|
+
# ---------------------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@app.websocket("/ws")
|
|
506
|
+
async def websocket_endpoint(ws: WebSocket) -> None:
|
|
507
|
+
"""Real-time stream of loki output and events."""
|
|
508
|
+
await ws.accept()
|
|
509
|
+
session.ws_clients.add(ws)
|
|
510
|
+
|
|
511
|
+
# Send current state on connect
|
|
512
|
+
await ws.send_text(json.dumps({
|
|
513
|
+
"type": "connected",
|
|
514
|
+
"data": {"running": session.running, "provider": session.provider},
|
|
515
|
+
}))
|
|
516
|
+
|
|
517
|
+
# Send recent log lines as backfill
|
|
518
|
+
for line in session.log_lines[-100:]:
|
|
519
|
+
await ws.send_text(json.dumps({
|
|
520
|
+
"type": "log",
|
|
521
|
+
"data": {"line": line, "timestamp": ""},
|
|
522
|
+
}))
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
while True:
|
|
526
|
+
# Keep connection alive; handle client messages if needed
|
|
527
|
+
data = await ws.receive_text()
|
|
528
|
+
# Could handle commands here (e.g., stop session)
|
|
529
|
+
try:
|
|
530
|
+
msg = json.loads(data)
|
|
531
|
+
if msg.get("type") == "ping":
|
|
532
|
+
await ws.send_text(json.dumps({"type": "pong"}))
|
|
533
|
+
except json.JSONDecodeError:
|
|
534
|
+
pass
|
|
535
|
+
except WebSocketDisconnect:
|
|
536
|
+
pass
|
|
537
|
+
finally:
|
|
538
|
+
session.ws_clients.discard(ws)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# ---------------------------------------------------------------------------
|
|
542
|
+
# Static file serving (built React app)
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
# Mount assets directory if dist exists
|
|
546
|
+
if DIST_DIR.is_dir() and (DIST_DIR / "assets").is_dir():
|
|
547
|
+
app.mount("/assets", StaticFiles(directory=str(DIST_DIR / "assets")), name="assets")
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@app.get("/{full_path:path}")
|
|
551
|
+
async def serve_spa(full_path: str) -> FileResponse:
|
|
552
|
+
"""Serve the React SPA. All non-API routes return index.html."""
|
|
553
|
+
index = DIST_DIR / "index.html"
|
|
554
|
+
if not index.exists():
|
|
555
|
+
return JSONResponse(
|
|
556
|
+
status_code=503,
|
|
557
|
+
content={"error": "Web app not built. Run: cd web-app && npm run build"},
|
|
558
|
+
)
|
|
559
|
+
# Try to serve static file first
|
|
560
|
+
requested = DIST_DIR / full_path
|
|
561
|
+
if full_path and requested.is_file() and str(requested.resolve()).startswith(str(DIST_DIR.resolve())):
|
|
562
|
+
return FileResponse(str(requested))
|
|
563
|
+
# Fallback to SPA index
|
|
564
|
+
return FileResponse(str(index))
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
# ---------------------------------------------------------------------------
|
|
568
|
+
# Entrypoint
|
|
569
|
+
# ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def main() -> None:
|
|
573
|
+
import uvicorn
|
|
574
|
+
host = os.environ.get("PURPLE_LAB_HOST", HOST)
|
|
575
|
+
port = int(os.environ.get("PURPLE_LAB_PORT", str(PORT)))
|
|
576
|
+
print(f"Purple Lab starting on http://{host}:{port}")
|
|
577
|
+
uvicorn.run(app, host=host, port=port, log_level="info")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
if __name__ == "__main__":
|
|
581
|
+
main()
|