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 CHANGED
@@ -10,11 +10,11 @@
10
10
  [![Autonomi](https://img.shields.io/badge/Autonomi-autonomi.dev-5B4EEA)](https://www.autonomi.dev/)
11
11
  [![Docker Pulls](https://img.shields.io/docker/pulls/asklokesh/loki-mode)](https://hub.docker.com/r/asklokesh/loki-mode)
12
12
 
13
- **Current Version: v6.26.2**
13
+ **Current Version: v6.27.0**
14
14
 
15
15
  ### Traction
16
16
 
17
- **737 stars** | **150 forks** | **10,200+ Docker pulls** | **18,000+ npm downloads** | **571+ commits** | **12 releases in a single day (March 18, 2026)**
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.0
1
+ 6.27.2
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "6.27.0"
10
+ __version__ = "6.27.2"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v6.27.0
5
+ **Version:** v6.27.2
6
6
 
7
7
  ---
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "6.27.0",
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()