loki-mode 6.45.1 → 6.50.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/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/web-app/dist/assets/{Badge-ClncXa1x.js → Badge--0P706gJ.js} +1 -1
- package/web-app/dist/assets/{Button-CUOgrX10.js → Button-CiWrzR-b.js} +1 -1
- package/web-app/dist/assets/{Card-2UWDXe0P.js → Card-kK4qakmW.js} +1 -1
- package/web-app/dist/assets/{HomePage-C4WxoEKI.js → HomePage-8Iht82oS.js} +1 -1
- package/web-app/dist/assets/LoginPage-QKE0uBAy.js +1 -0
- package/web-app/dist/assets/NotFoundPage-CyiH17vK.js +1 -0
- package/web-app/dist/assets/ProjectPage-9CEnUXvW.css +32 -0
- package/web-app/dist/assets/ProjectPage-Bv_bjjwT.js +184 -0
- package/web-app/dist/assets/{ProjectsPage-CIDkRHyZ.js → ProjectsPage-DaB2tqOQ.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-5AxjoTTg.js → SettingsPage-gZBenmpt.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-DPlaAtAk.js → TemplatesPage-BGHgxn_a.js} +1 -1
- package/web-app/dist/assets/TerminalOutput-C7sYzcHM.js +51 -0
- package/web-app/dist/assets/arrow-left-C56w2CVD.js +6 -0
- package/web-app/dist/assets/{clock-DpWpY1Zx.js → clock-CybOBArs.js} +1 -1
- package/web-app/dist/assets/{external-link-KmF9dPsz.js → external-link-DN8r7gOC.js} +1 -1
- package/web-app/dist/assets/index-UNfgZjJl.css +1 -0
- package/web-app/dist/assets/index-g4lAt51o.js +186 -0
- package/web-app/dist/index.html +2 -2
- package/web-app/server.py +1340 -47
- package/web-app/dist/assets/ProjectPage-Dy7ONtf_.js +0 -162
- package/web-app/dist/assets/TerminalOutput-Do2PhilR.js +0 -31
- package/web-app/dist/assets/index-ACgjqVp2.js +0 -136
- package/web-app/dist/assets/index-BZpAV5M8.css +0 -1
package/web-app/server.py
CHANGED
|
@@ -9,7 +9,6 @@ Runs on port 57375 (dashboard uses 57374).
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
12
|
-
import inspect
|
|
13
12
|
import json
|
|
14
13
|
import os
|
|
15
14
|
import re
|
|
@@ -19,14 +18,27 @@ import sys
|
|
|
19
18
|
import time
|
|
20
19
|
import uuid
|
|
21
20
|
from pathlib import Path
|
|
22
|
-
from typing import Optional
|
|
21
|
+
from typing import Dict, Optional
|
|
23
22
|
|
|
24
|
-
|
|
23
|
+
try:
|
|
24
|
+
import pexpect
|
|
25
|
+
HAS_PEXPECT = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
HAS_PEXPECT = False
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import threading
|
|
31
|
+
|
|
32
|
+
from datetime import datetime
|
|
33
|
+
from fastapi import Body, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
25
34
|
from fastapi.middleware.cors import CORSMiddleware
|
|
26
|
-
from fastapi.responses import FileResponse, JSONResponse
|
|
35
|
+
from fastapi.responses import FileResponse, JSONResponse, Response
|
|
36
|
+
from starlette.responses import StreamingResponse
|
|
27
37
|
from fastapi.staticfiles import StaticFiles
|
|
28
38
|
from pydantic import BaseModel
|
|
29
39
|
|
|
40
|
+
logger = logging.getLogger("purple-lab")
|
|
41
|
+
|
|
30
42
|
# ---------------------------------------------------------------------------
|
|
31
43
|
# Configuration
|
|
32
44
|
# ---------------------------------------------------------------------------
|
|
@@ -143,6 +155,9 @@ def _kill_tracked_child_processes() -> None:
|
|
|
143
155
|
|
|
144
156
|
session = SessionState()
|
|
145
157
|
|
|
158
|
+
# Terminal PTY instances keyed by session_id
|
|
159
|
+
_terminal_ptys: Dict[str, "pexpect.spawn"] = {}
|
|
160
|
+
|
|
146
161
|
# Track PIDs of sessions started by Purple Lab (not by external loki CLI)
|
|
147
162
|
_PURPLE_LAB_PIDS_FILE = SCRIPT_DIR.parent / ".loki" / "purple-lab" / "child-pids.json"
|
|
148
163
|
|
|
@@ -249,6 +264,490 @@ class SecretRequest(BaseModel):
|
|
|
249
264
|
key: str
|
|
250
265
|
value: str
|
|
251
266
|
|
|
267
|
+
|
|
268
|
+
class DevServerStartRequest(BaseModel):
|
|
269
|
+
command: Optional[str] = None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
# File Watcher (watchdog-based, broadcasts changes via WebSocket)
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
from watchdog.observers import Observer
|
|
278
|
+
from watchdog.events import FileSystemEventHandler, FileSystemEvent
|
|
279
|
+
HAS_WATCHDOG = True
|
|
280
|
+
except ImportError:
|
|
281
|
+
HAS_WATCHDOG = False
|
|
282
|
+
Observer = None # type: ignore[misc,assignment]
|
|
283
|
+
FileSystemEventHandler = object # type: ignore[misc,assignment]
|
|
284
|
+
|
|
285
|
+
# Patterns to ignore when watching for file changes
|
|
286
|
+
_WATCH_IGNORE_DIRS = {".loki", "node_modules", ".git", "__pycache__", ".next", ".nuxt", "dist", "build", ".cache"}
|
|
287
|
+
_WATCH_IGNORE_EXTENSIONS = {".pyc", ".pyo", ".swp", ".swo", ".swn", ".tmp", ".DS_Store"}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class FileChangeHandler(FileSystemEventHandler): # type: ignore[misc]
|
|
291
|
+
"""Collects file system events and broadcasts them after a debounce window."""
|
|
292
|
+
|
|
293
|
+
def __init__(self, project_dir: str, broadcast_fn, loop: asyncio.AbstractEventLoop):
|
|
294
|
+
super().__init__()
|
|
295
|
+
self.project_dir = project_dir
|
|
296
|
+
self.broadcast_fn = broadcast_fn
|
|
297
|
+
self.loop = loop
|
|
298
|
+
self._lock = threading.Lock()
|
|
299
|
+
self._pending: list[dict] = []
|
|
300
|
+
self._debounce_handle: Optional[asyncio.TimerHandle] = None
|
|
301
|
+
|
|
302
|
+
def _should_ignore(self, path: str) -> bool:
|
|
303
|
+
"""Return True if this path should be ignored."""
|
|
304
|
+
parts = Path(path).parts
|
|
305
|
+
for part in parts:
|
|
306
|
+
if part in _WATCH_IGNORE_DIRS:
|
|
307
|
+
return True
|
|
308
|
+
name = os.path.basename(path)
|
|
309
|
+
if name in _WATCH_IGNORE_EXTENSIONS:
|
|
310
|
+
return True
|
|
311
|
+
_, ext = os.path.splitext(name)
|
|
312
|
+
if ext in _WATCH_IGNORE_EXTENSIONS:
|
|
313
|
+
return True
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
def on_any_event(self, event) -> None: # type: ignore[override]
|
|
317
|
+
if event.is_directory and event.event_type not in ("created", "deleted"):
|
|
318
|
+
return
|
|
319
|
+
src = getattr(event, "src_path", "")
|
|
320
|
+
if not src or self._should_ignore(src):
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
with self._lock:
|
|
324
|
+
self._pending.append({
|
|
325
|
+
"path": src,
|
|
326
|
+
"event_type": event.event_type,
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
# Schedule debounced broadcast on the asyncio event loop
|
|
330
|
+
try:
|
|
331
|
+
self.loop.call_soon_threadsafe(self._schedule_broadcast)
|
|
332
|
+
except RuntimeError:
|
|
333
|
+
pass # loop closed
|
|
334
|
+
|
|
335
|
+
def _schedule_broadcast(self) -> None:
|
|
336
|
+
"""Schedule the broadcast after 200ms of quiet."""
|
|
337
|
+
if self._debounce_handle is not None:
|
|
338
|
+
self._debounce_handle.cancel()
|
|
339
|
+
self._debounce_handle = self.loop.call_later(0.2, self._fire_broadcast)
|
|
340
|
+
|
|
341
|
+
def _fire_broadcast(self) -> None:
|
|
342
|
+
"""Collect pending changes and broadcast them."""
|
|
343
|
+
with self._lock:
|
|
344
|
+
changes = self._pending[:]
|
|
345
|
+
self._pending.clear()
|
|
346
|
+
self._debounce_handle = None
|
|
347
|
+
|
|
348
|
+
if not changes:
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
# Deduplicate by path, keeping the last event type
|
|
352
|
+
seen: dict[str, str] = {}
|
|
353
|
+
for c in changes:
|
|
354
|
+
seen[c["path"]] = c["event_type"]
|
|
355
|
+
|
|
356
|
+
raw_paths = list(seen.keys())
|
|
357
|
+
event_types = [seen[p] for p in raw_paths]
|
|
358
|
+
|
|
359
|
+
# Strip project dir prefix to get relative paths for the frontend
|
|
360
|
+
prefix = self.project_dir.rstrip("/") + "/"
|
|
361
|
+
paths = [p[len(prefix):] if p.startswith(prefix) else p for p in raw_paths]
|
|
362
|
+
|
|
363
|
+
asyncio.ensure_future(self.broadcast_fn({
|
|
364
|
+
"type": "file_changed",
|
|
365
|
+
"data": {"paths": paths, "event_types": event_types},
|
|
366
|
+
}))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class FileWatcher:
|
|
370
|
+
"""Manages watchdog observers for project directories."""
|
|
371
|
+
|
|
372
|
+
def __init__(self) -> None:
|
|
373
|
+
self._observers: Dict[str, "Observer"] = {} # type: ignore[type-arg]
|
|
374
|
+
|
|
375
|
+
def start(self, key: str, project_dir: str, broadcast_fn, loop: asyncio.AbstractEventLoop) -> bool:
|
|
376
|
+
"""Start watching a project directory. Returns True if started."""
|
|
377
|
+
if not HAS_WATCHDOG:
|
|
378
|
+
logger.info("watchdog not installed -- file watcher disabled")
|
|
379
|
+
return False
|
|
380
|
+
|
|
381
|
+
if key in self._observers:
|
|
382
|
+
self.stop(key)
|
|
383
|
+
|
|
384
|
+
if not os.path.isdir(project_dir):
|
|
385
|
+
return False
|
|
386
|
+
|
|
387
|
+
handler = FileChangeHandler(project_dir, broadcast_fn, loop)
|
|
388
|
+
observer = Observer()
|
|
389
|
+
observer.schedule(handler, project_dir, recursive=True)
|
|
390
|
+
observer.daemon = True
|
|
391
|
+
observer.start()
|
|
392
|
+
self._observers[key] = observer
|
|
393
|
+
logger.info("File watcher started for %s", project_dir)
|
|
394
|
+
return True
|
|
395
|
+
|
|
396
|
+
def stop(self, key: str) -> None:
|
|
397
|
+
"""Stop watching."""
|
|
398
|
+
observer = self._observers.pop(key, None)
|
|
399
|
+
if observer is not None:
|
|
400
|
+
observer.stop()
|
|
401
|
+
observer.join(timeout=3)
|
|
402
|
+
logger.info("File watcher stopped for key=%s", key)
|
|
403
|
+
|
|
404
|
+
def stop_all(self) -> None:
|
|
405
|
+
"""Stop all watchers."""
|
|
406
|
+
for key in list(self._observers):
|
|
407
|
+
self.stop(key)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
file_watcher = FileWatcher()
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
# Dev Server Manager
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class DevServerManager:
|
|
419
|
+
"""Manages dev server processes per session."""
|
|
420
|
+
|
|
421
|
+
_PORT_PATTERNS = [
|
|
422
|
+
# Vite: " Local: http://localhost:5173/"
|
|
423
|
+
re.compile(r"Local:\s+https?://localhost:(\d+)"),
|
|
424
|
+
# Next.js: " - Local: http://localhost:3000"
|
|
425
|
+
re.compile(r"-\s+Local:\s+https?://localhost:(\d+)"),
|
|
426
|
+
# Django: "Starting development server at http://127.0.0.1:8000/"
|
|
427
|
+
re.compile(r"server\s+at\s+https?://127\.0\.0\.1:(\d+)", re.IGNORECASE),
|
|
428
|
+
# Flask: " * Running on http://127.0.0.1:5000"
|
|
429
|
+
re.compile(r"Running\s+on\s+https?://127\.0\.0\.1:(\d+)"),
|
|
430
|
+
# Go: "Listening on :8080"
|
|
431
|
+
re.compile(r"(?:listening|serving)\s+on\s+:(\d+)", re.IGNORECASE),
|
|
432
|
+
# Generic "listening on port 3000" or "on port 3000"
|
|
433
|
+
re.compile(r"listening\s+on\s+(?:port\s+)?(\d+)", re.IGNORECASE),
|
|
434
|
+
re.compile(r"on\s+port\s+(\d+)", re.IGNORECASE),
|
|
435
|
+
# "port 3000" standalone
|
|
436
|
+
re.compile(r"port\s+(\d+)", re.IGNORECASE),
|
|
437
|
+
# Vite ready message: "ready in 300ms -- http://localhost:5173/"
|
|
438
|
+
re.compile(r"ready\s+in\s+\d+m?s.*localhost:(\d+)"),
|
|
439
|
+
# Generic URL patterns (last resort -- broad matches)
|
|
440
|
+
re.compile(r"https?://0\.0\.0\.0:(\d+)"),
|
|
441
|
+
re.compile(r"https?://127\.0\.0\.1:(\d+)"),
|
|
442
|
+
re.compile(r"https?://localhost:(\d+)"),
|
|
443
|
+
]
|
|
444
|
+
|
|
445
|
+
def __init__(self) -> None:
|
|
446
|
+
self.servers: Dict[str, dict] = {}
|
|
447
|
+
|
|
448
|
+
async def detect_dev_command(self, project_dir: str) -> Optional[dict]:
|
|
449
|
+
"""Detect the dev command from project files."""
|
|
450
|
+
root = Path(project_dir)
|
|
451
|
+
if not root.is_dir():
|
|
452
|
+
return None
|
|
453
|
+
|
|
454
|
+
pkg_json = root / "package.json"
|
|
455
|
+
if pkg_json.exists():
|
|
456
|
+
try:
|
|
457
|
+
pkg = json.loads(pkg_json.read_text())
|
|
458
|
+
scripts = pkg.get("scripts", {})
|
|
459
|
+
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
|
460
|
+
if "dev" in scripts:
|
|
461
|
+
port = 5173 if "vite" in deps else 3000
|
|
462
|
+
fw = "vite" if "vite" in deps else "next" if "next" in deps else "node"
|
|
463
|
+
return {"command": "npm run dev", "expected_port": port, "framework": fw}
|
|
464
|
+
if "start" in scripts:
|
|
465
|
+
fw = "next" if "next" in deps else "react" if "react" in deps else "node"
|
|
466
|
+
return {"command": "npm start", "expected_port": 3000, "framework": fw}
|
|
467
|
+
if "serve" in scripts:
|
|
468
|
+
return {"command": "npm run serve", "expected_port": 3000, "framework": "node"}
|
|
469
|
+
except (json.JSONDecodeError, OSError):
|
|
470
|
+
pass
|
|
471
|
+
|
|
472
|
+
makefile = root / "Makefile"
|
|
473
|
+
if makefile.exists():
|
|
474
|
+
try:
|
|
475
|
+
content = makefile.read_text()
|
|
476
|
+
for target in ("dev", "run", "serve"):
|
|
477
|
+
if re.search(rf"^{target}\s*:", content, re.MULTILINE):
|
|
478
|
+
return {"command": f"make {target}", "expected_port": 8000, "framework": "make"}
|
|
479
|
+
except OSError:
|
|
480
|
+
pass
|
|
481
|
+
|
|
482
|
+
if (root / "manage.py").exists():
|
|
483
|
+
return {"command": "python manage.py runserver", "expected_port": 8000, "framework": "django"}
|
|
484
|
+
|
|
485
|
+
for py_entry in ("app.py", "main.py", "server.py"):
|
|
486
|
+
py_file = root / py_entry
|
|
487
|
+
if py_file.exists():
|
|
488
|
+
try:
|
|
489
|
+
src = py_file.read_text(errors="replace")
|
|
490
|
+
if "fastapi" in src.lower() or "FastAPI" in src:
|
|
491
|
+
module = py_entry[:-3]
|
|
492
|
+
return {"command": f"uvicorn {module}:app --reload --port 8000",
|
|
493
|
+
"expected_port": 8000, "framework": "fastapi"}
|
|
494
|
+
if "flask" in src.lower() or "Flask" in src:
|
|
495
|
+
return {"command": "flask run --port 5000",
|
|
496
|
+
"expected_port": 5000, "framework": "flask"}
|
|
497
|
+
except OSError:
|
|
498
|
+
pass
|
|
499
|
+
|
|
500
|
+
if (root / "go.mod").exists():
|
|
501
|
+
return {"command": "go run .", "expected_port": 8080, "framework": "go"}
|
|
502
|
+
if (root / "Cargo.toml").exists():
|
|
503
|
+
return {"command": "cargo run", "expected_port": 8080, "framework": "rust"}
|
|
504
|
+
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
def _parse_port(self, output: str) -> Optional[int]:
|
|
508
|
+
"""Parse port from dev server stdout."""
|
|
509
|
+
for pattern in self._PORT_PATTERNS:
|
|
510
|
+
m = pattern.search(output)
|
|
511
|
+
if m:
|
|
512
|
+
port = int(m.group(1))
|
|
513
|
+
if 1024 <= port <= 65535:
|
|
514
|
+
return port
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
async def start(self, session_id: str, project_dir: str, command: Optional[str] = None) -> dict:
|
|
518
|
+
"""Start dev server. Auto-detect command if not provided."""
|
|
519
|
+
if session_id in self.servers:
|
|
520
|
+
await self.stop(session_id)
|
|
521
|
+
|
|
522
|
+
detected = await self.detect_dev_command(project_dir)
|
|
523
|
+
if not command and not detected:
|
|
524
|
+
return {"status": "error", "message": "No dev command detected. Provide one explicitly."}
|
|
525
|
+
|
|
526
|
+
cmd_str = command or (detected["command"] if detected else "")
|
|
527
|
+
expected_port = detected["expected_port"] if detected else 3000
|
|
528
|
+
framework = detected["framework"] if detected else "unknown"
|
|
529
|
+
|
|
530
|
+
build_env = {**os.environ}
|
|
531
|
+
build_env.update(_load_secrets())
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
proc = subprocess.Popen(
|
|
535
|
+
cmd_str,
|
|
536
|
+
shell=True,
|
|
537
|
+
stdout=subprocess.PIPE,
|
|
538
|
+
stderr=subprocess.STDOUT,
|
|
539
|
+
stdin=subprocess.DEVNULL,
|
|
540
|
+
text=True,
|
|
541
|
+
cwd=project_dir,
|
|
542
|
+
env=build_env,
|
|
543
|
+
**({"start_new_session": True} if sys.platform != "win32"
|
|
544
|
+
else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}),
|
|
545
|
+
)
|
|
546
|
+
except Exception as e:
|
|
547
|
+
return {"status": "error", "message": f"Failed to start: {e}"}
|
|
548
|
+
|
|
549
|
+
server_info: dict = {
|
|
550
|
+
"process": proc,
|
|
551
|
+
"port": None,
|
|
552
|
+
"expected_port": expected_port,
|
|
553
|
+
"command": cmd_str,
|
|
554
|
+
"framework": framework,
|
|
555
|
+
"status": "starting",
|
|
556
|
+
"pid": proc.pid,
|
|
557
|
+
"project_dir": project_dir,
|
|
558
|
+
"output_lines": [],
|
|
559
|
+
}
|
|
560
|
+
self.servers[session_id] = server_info
|
|
561
|
+
|
|
562
|
+
asyncio.create_task(self._monitor_output(session_id))
|
|
563
|
+
|
|
564
|
+
# Wait for port detection (up to 30s)
|
|
565
|
+
for _ in range(60):
|
|
566
|
+
await asyncio.sleep(0.5)
|
|
567
|
+
info = self.servers.get(session_id)
|
|
568
|
+
if not info:
|
|
569
|
+
return {"status": "error", "message": "Server entry disappeared"}
|
|
570
|
+
if info["status"] == "error":
|
|
571
|
+
return {
|
|
572
|
+
"status": "error",
|
|
573
|
+
"message": "Dev server crashed",
|
|
574
|
+
"output": info["output_lines"][-10:] if info["output_lines"] else [],
|
|
575
|
+
}
|
|
576
|
+
if info["port"] is not None:
|
|
577
|
+
health_ok = await self._health_check(info["port"])
|
|
578
|
+
if health_ok:
|
|
579
|
+
info["status"] = "running"
|
|
580
|
+
return {
|
|
581
|
+
"status": "running",
|
|
582
|
+
"port": info["port"],
|
|
583
|
+
"command": cmd_str,
|
|
584
|
+
"pid": proc.pid,
|
|
585
|
+
"url": f"/proxy/{session_id}/",
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if proc.poll() is not None:
|
|
589
|
+
server_info["status"] = "error"
|
|
590
|
+
return {
|
|
591
|
+
"status": "error",
|
|
592
|
+
"message": "Dev server exited before port was detected",
|
|
593
|
+
"output": server_info["output_lines"][-10:],
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
health_ok = await self._health_check(expected_port)
|
|
597
|
+
if health_ok:
|
|
598
|
+
server_info["port"] = expected_port
|
|
599
|
+
server_info["status"] = "running"
|
|
600
|
+
return {
|
|
601
|
+
"status": "running",
|
|
602
|
+
"port": expected_port,
|
|
603
|
+
"command": cmd_str,
|
|
604
|
+
"pid": proc.pid,
|
|
605
|
+
"url": f"/proxy/{session_id}/",
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
server_info["status"] = "starting"
|
|
609
|
+
server_info["port"] = expected_port
|
|
610
|
+
return {
|
|
611
|
+
"status": "starting",
|
|
612
|
+
"message": "Server started but port not yet confirmed",
|
|
613
|
+
"port": expected_port,
|
|
614
|
+
"command": cmd_str,
|
|
615
|
+
"pid": proc.pid,
|
|
616
|
+
"url": f"/proxy/{session_id}/",
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async def _monitor_output(self, session_id: str) -> None:
|
|
620
|
+
"""Background task: read dev server stdout and detect port."""
|
|
621
|
+
info = self.servers.get(session_id)
|
|
622
|
+
if not info:
|
|
623
|
+
return
|
|
624
|
+
proc = info["process"]
|
|
625
|
+
loop = asyncio.get_running_loop()
|
|
626
|
+
try:
|
|
627
|
+
while proc.poll() is None:
|
|
628
|
+
line = await loop.run_in_executor(None, proc.stdout.readline)
|
|
629
|
+
if not line:
|
|
630
|
+
break
|
|
631
|
+
text = line.rstrip("\n")
|
|
632
|
+
info["output_lines"].append(text)
|
|
633
|
+
if len(info["output_lines"]) > 200:
|
|
634
|
+
info["output_lines"] = info["output_lines"][-200:]
|
|
635
|
+
if info["port"] is None:
|
|
636
|
+
detected_port = self._parse_port(text)
|
|
637
|
+
if detected_port:
|
|
638
|
+
info["port"] = detected_port
|
|
639
|
+
except Exception:
|
|
640
|
+
pass
|
|
641
|
+
finally:
|
|
642
|
+
# Process exited -- mark as error if it was still starting or running
|
|
643
|
+
if info.get("status") in ("starting", "running"):
|
|
644
|
+
info["status"] = "error"
|
|
645
|
+
|
|
646
|
+
async def _health_check(self, port: int, retries: int = 3) -> bool:
|
|
647
|
+
"""Check if a port is responding to TCP connections."""
|
|
648
|
+
import socket
|
|
649
|
+
loop = asyncio.get_running_loop()
|
|
650
|
+
for _ in range(retries):
|
|
651
|
+
try:
|
|
652
|
+
def check() -> bool:
|
|
653
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
654
|
+
s.settimeout(1)
|
|
655
|
+
try:
|
|
656
|
+
s.connect(("127.0.0.1", port))
|
|
657
|
+
return True
|
|
658
|
+
except (ConnectionRefusedError, OSError):
|
|
659
|
+
return False
|
|
660
|
+
finally:
|
|
661
|
+
s.close()
|
|
662
|
+
if await loop.run_in_executor(None, check):
|
|
663
|
+
return True
|
|
664
|
+
except Exception:
|
|
665
|
+
pass
|
|
666
|
+
await asyncio.sleep(0.5)
|
|
667
|
+
return False
|
|
668
|
+
|
|
669
|
+
async def stop(self, session_id: str) -> dict:
|
|
670
|
+
"""Stop dev server for session."""
|
|
671
|
+
info = self.servers.pop(session_id, None)
|
|
672
|
+
if not info:
|
|
673
|
+
return {"stopped": False, "message": "No dev server running"}
|
|
674
|
+
|
|
675
|
+
proc = info["process"]
|
|
676
|
+
if proc.poll() is None:
|
|
677
|
+
if sys.platform != "win32":
|
|
678
|
+
try:
|
|
679
|
+
pgid = os.getpgid(proc.pid)
|
|
680
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
681
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
682
|
+
try:
|
|
683
|
+
proc.terminate()
|
|
684
|
+
except Exception:
|
|
685
|
+
pass
|
|
686
|
+
else:
|
|
687
|
+
try:
|
|
688
|
+
proc.terminate()
|
|
689
|
+
except Exception:
|
|
690
|
+
pass
|
|
691
|
+
try:
|
|
692
|
+
proc.wait(timeout=5)
|
|
693
|
+
except subprocess.TimeoutExpired:
|
|
694
|
+
if sys.platform != "win32":
|
|
695
|
+
try:
|
|
696
|
+
pgid = os.getpgid(proc.pid)
|
|
697
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
698
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
699
|
+
try:
|
|
700
|
+
proc.kill()
|
|
701
|
+
except Exception:
|
|
702
|
+
pass
|
|
703
|
+
else:
|
|
704
|
+
try:
|
|
705
|
+
proc.kill()
|
|
706
|
+
except Exception:
|
|
707
|
+
pass
|
|
708
|
+
|
|
709
|
+
return {"stopped": True, "message": "Dev server stopped"}
|
|
710
|
+
|
|
711
|
+
async def status(self, session_id: str) -> dict:
|
|
712
|
+
"""Get dev server status."""
|
|
713
|
+
info = self.servers.get(session_id)
|
|
714
|
+
if not info:
|
|
715
|
+
return {
|
|
716
|
+
"running": False,
|
|
717
|
+
"status": "stopped",
|
|
718
|
+
"port": None,
|
|
719
|
+
"command": None,
|
|
720
|
+
"pid": None,
|
|
721
|
+
"url": None,
|
|
722
|
+
"framework": None,
|
|
723
|
+
"output": [],
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
proc = info["process"]
|
|
727
|
+
alive = proc.poll() is None
|
|
728
|
+
if not alive and info["status"] in ("running", "starting"):
|
|
729
|
+
info["status"] = "error"
|
|
730
|
+
|
|
731
|
+
return {
|
|
732
|
+
"running": alive and info["status"] == "running",
|
|
733
|
+
"status": info["status"],
|
|
734
|
+
"port": info.get("port"),
|
|
735
|
+
"command": info.get("command"),
|
|
736
|
+
"pid": proc.pid if alive else None,
|
|
737
|
+
"url": f"/proxy/{session_id}/" if info.get("port") and alive else None,
|
|
738
|
+
"framework": info.get("framework"),
|
|
739
|
+
"output": info.get("output_lines", [])[-20:],
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async def stop_all(self) -> None:
|
|
743
|
+
"""Stop all dev servers (used on shutdown)."""
|
|
744
|
+
for sid in list(self.servers.keys()):
|
|
745
|
+
await self.stop(sid)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
dev_server_manager = DevServerManager()
|
|
749
|
+
|
|
750
|
+
|
|
252
751
|
# ---------------------------------------------------------------------------
|
|
253
752
|
# Helpers
|
|
254
753
|
# ---------------------------------------------------------------------------
|
|
@@ -412,9 +911,35 @@ class ChatTask:
|
|
|
412
911
|
self.complete = False
|
|
413
912
|
self.returncode: int = -1
|
|
414
913
|
self.files_changed: list[str] = []
|
|
914
|
+
self.process: Optional[subprocess.Popen] = None
|
|
915
|
+
self.cancelled = False
|
|
916
|
+
self.created_at: float = time.time()
|
|
415
917
|
|
|
416
918
|
|
|
417
919
|
_chat_tasks: dict[str, ChatTask] = {}
|
|
920
|
+
_CHAT_TASK_MAX_AGE = 600 # Seconds to keep completed tasks before cleanup
|
|
921
|
+
_CHAT_TASK_MAX_COUNT = 100 # Max tasks to keep in memory
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _cleanup_chat_tasks() -> None:
|
|
925
|
+
"""Remove completed tasks older than _CHAT_TASK_MAX_AGE, or oldest if over limit."""
|
|
926
|
+
now = time.time()
|
|
927
|
+
# Remove old completed tasks
|
|
928
|
+
expired = [
|
|
929
|
+
tid for tid, t in _chat_tasks.items()
|
|
930
|
+
if t.complete and (now - t.created_at) > _CHAT_TASK_MAX_AGE
|
|
931
|
+
]
|
|
932
|
+
for tid in expired:
|
|
933
|
+
del _chat_tasks[tid]
|
|
934
|
+
# If still over limit, remove oldest completed tasks
|
|
935
|
+
if len(_chat_tasks) > _CHAT_TASK_MAX_COUNT:
|
|
936
|
+
completed = sorted(
|
|
937
|
+
[(tid, t) for tid, t in _chat_tasks.items() if t.complete],
|
|
938
|
+
key=lambda x: x[1].created_at,
|
|
939
|
+
)
|
|
940
|
+
while len(_chat_tasks) > _CHAT_TASK_MAX_COUNT and completed:
|
|
941
|
+
tid, _ = completed.pop(0)
|
|
942
|
+
del _chat_tasks[tid]
|
|
418
943
|
|
|
419
944
|
# ---------------------------------------------------------------------------
|
|
420
945
|
# API endpoints
|
|
@@ -507,6 +1032,14 @@ async def start_session(req: StartRequest) -> JSONResponse:
|
|
|
507
1032
|
# Start background output reader
|
|
508
1033
|
session._reader_task = asyncio.create_task(_read_process_output())
|
|
509
1034
|
|
|
1035
|
+
# Start file watcher for the project directory
|
|
1036
|
+
file_watcher.start(
|
|
1037
|
+
"session",
|
|
1038
|
+
project_dir,
|
|
1039
|
+
_broadcast,
|
|
1040
|
+
asyncio.get_running_loop(),
|
|
1041
|
+
)
|
|
1042
|
+
|
|
510
1043
|
await _broadcast({"type": "session_start", "data": {
|
|
511
1044
|
"provider": req.provider,
|
|
512
1045
|
"projectDir": project_dir,
|
|
@@ -577,6 +1110,23 @@ async def stop_session() -> JSONResponse:
|
|
|
577
1110
|
except Exception:
|
|
578
1111
|
pass
|
|
579
1112
|
|
|
1113
|
+
# Stop file watcher
|
|
1114
|
+
file_watcher.stop("session")
|
|
1115
|
+
|
|
1116
|
+
# Stop dev server if running for this session
|
|
1117
|
+
# (The current active session uses "session" as its key for file_watcher
|
|
1118
|
+
# but dev servers are keyed by session_id from the URL. Stop all to be safe.)
|
|
1119
|
+
await dev_server_manager.stop_all()
|
|
1120
|
+
|
|
1121
|
+
# Clean up any terminal PTYs
|
|
1122
|
+
for sid, pty in list(_terminal_ptys.items()):
|
|
1123
|
+
try:
|
|
1124
|
+
if pty.isalive():
|
|
1125
|
+
pty.close(force=True)
|
|
1126
|
+
except Exception:
|
|
1127
|
+
pass
|
|
1128
|
+
_terminal_ptys.clear()
|
|
1129
|
+
|
|
580
1130
|
# Kill any orphaned loki-run processes for this project
|
|
581
1131
|
if session.project_dir:
|
|
582
1132
|
await asyncio.get_running_loop().run_in_executor(
|
|
@@ -588,9 +1138,41 @@ async def stop_session() -> JSONResponse:
|
|
|
588
1138
|
return JSONResponse(content={"stopped": True, "message": "Session stopped"})
|
|
589
1139
|
|
|
590
1140
|
|
|
1141
|
+
@app.on_event("startup")
|
|
1142
|
+
async def startup_event():
|
|
1143
|
+
"""Initialize database if DATABASE_URL is configured."""
|
|
1144
|
+
try:
|
|
1145
|
+
from models import init_db
|
|
1146
|
+
db_url = os.environ.get("DATABASE_URL")
|
|
1147
|
+
if db_url:
|
|
1148
|
+
await init_db(db_url)
|
|
1149
|
+
logger.info("Database initialized")
|
|
1150
|
+
else:
|
|
1151
|
+
logger.info("No DATABASE_URL set -- running in local mode (no auth, file-based storage)")
|
|
1152
|
+
except ImportError:
|
|
1153
|
+
logger.info("Database models not available -- running in local mode")
|
|
1154
|
+
except Exception as exc:
|
|
1155
|
+
logger.warning("Database initialization failed: %s -- falling back to local mode", exc)
|
|
1156
|
+
|
|
1157
|
+
|
|
591
1158
|
@app.on_event("shutdown")
|
|
592
1159
|
async def shutdown_event():
|
|
593
|
-
"""Clean up any running session when Purple Lab shuts down."""
|
|
1160
|
+
"""Clean up any running session, file watchers, and dev servers when Purple Lab shuts down."""
|
|
1161
|
+
# Stop all file watchers
|
|
1162
|
+
file_watcher.stop_all()
|
|
1163
|
+
|
|
1164
|
+
# Stop all dev servers
|
|
1165
|
+
await dev_server_manager.stop_all()
|
|
1166
|
+
|
|
1167
|
+
# Clean up all terminal PTYs
|
|
1168
|
+
for _sid, _pty in list(_terminal_ptys.items()):
|
|
1169
|
+
try:
|
|
1170
|
+
if _pty.isalive():
|
|
1171
|
+
_pty.close(force=True)
|
|
1172
|
+
except Exception:
|
|
1173
|
+
pass
|
|
1174
|
+
_terminal_ptys.clear()
|
|
1175
|
+
|
|
594
1176
|
if not session.running or session.process is None:
|
|
595
1177
|
return
|
|
596
1178
|
|
|
@@ -639,7 +1221,7 @@ async def shutdown_event():
|
|
|
639
1221
|
# Kill any orphaned loki-run processes for this project
|
|
640
1222
|
if project_dir:
|
|
641
1223
|
await asyncio.get_running_loop().run_in_executor(
|
|
642
|
-
None,
|
|
1224
|
+
None, _kill_tracked_child_processes
|
|
643
1225
|
)
|
|
644
1226
|
|
|
645
1227
|
|
|
@@ -986,11 +1568,7 @@ async def plan_session(req: PlanRequest) -> JSONResponse:
|
|
|
986
1568
|
pass
|
|
987
1569
|
|
|
988
1570
|
# Try to parse structured JSON from output first (loki plan may emit JSON blocks)
|
|
989
|
-
|
|
990
|
-
import logging as _logging
|
|
991
|
-
import re as _re
|
|
992
|
-
|
|
993
|
-
_log = _logging.getLogger("purple-lab.plan")
|
|
1571
|
+
_log = logging.getLogger("purple-lab.plan")
|
|
994
1572
|
|
|
995
1573
|
complexity = "standard"
|
|
996
1574
|
cost_estimate = "unknown"
|
|
@@ -999,12 +1577,12 @@ async def plan_session(req: PlanRequest) -> JSONResponse:
|
|
|
999
1577
|
parsed = False
|
|
1000
1578
|
|
|
1001
1579
|
# Look for any JSON object containing plan-related keys (supports nested braces)
|
|
1002
|
-
json_match =
|
|
1580
|
+
json_match = re.search(r'\{[^{}]*"complexity"[^{}]*\}', output, re.DOTALL)
|
|
1003
1581
|
if not json_match:
|
|
1004
|
-
json_match =
|
|
1582
|
+
json_match = re.search(r'\{[^{}]*"iterations"[^{}]*\}', output, re.DOTALL)
|
|
1005
1583
|
if json_match:
|
|
1006
1584
|
try:
|
|
1007
|
-
data =
|
|
1585
|
+
data = json.loads(json_match.group(0))
|
|
1008
1586
|
if isinstance(data.get("complexity"), dict):
|
|
1009
1587
|
complexity = data["complexity"].get("tier", "standard")
|
|
1010
1588
|
elif isinstance(data.get("complexity"), str):
|
|
@@ -1026,37 +1604,37 @@ async def plan_session(req: PlanRequest) -> JSONResponse:
|
|
|
1026
1604
|
elif isinstance(data.get("phases"), list):
|
|
1027
1605
|
phases = [p for p in data["phases"] if isinstance(p, str)]
|
|
1028
1606
|
parsed = True
|
|
1029
|
-
except (
|
|
1607
|
+
except (json.JSONDecodeError, TypeError, KeyError) as exc:
|
|
1030
1608
|
_log.warning("JSON plan block found but failed to parse: %s", exc)
|
|
1031
1609
|
|
|
1032
1610
|
# Fallback: line-by-line text parsing with tighter patterns
|
|
1033
1611
|
if not parsed:
|
|
1034
1612
|
_log.info("No JSON plan block found, falling back to text parsing")
|
|
1035
1613
|
for line in output.splitlines():
|
|
1036
|
-
stripped =
|
|
1614
|
+
stripped = re.sub(r'\x1b\[[0-9;]*m', '', line) # strip ANSI codes
|
|
1037
1615
|
lower = stripped.lower().strip()
|
|
1038
1616
|
if not lower:
|
|
1039
1617
|
continue
|
|
1040
1618
|
# Complexity detection: match "complexity: standard" or "Complexity Tier: complex" etc.
|
|
1041
|
-
if
|
|
1619
|
+
if re.search(r'complexity\s*(?:tier)?\s*[:=]', lower):
|
|
1042
1620
|
for val in ("simple", "standard", "complex", "expert"):
|
|
1043
|
-
if
|
|
1621
|
+
if re.search(rf'\b{val}\b', lower):
|
|
1044
1622
|
complexity = val
|
|
1045
1623
|
break
|
|
1046
1624
|
# Cost parsing: look for dollar amounts in cost/estimate lines
|
|
1047
1625
|
if ("cost" in lower or "estimate" in lower) and "$" in stripped:
|
|
1048
|
-
m =
|
|
1626
|
+
m = re.search(r"\$[\d,]+\.?\d*", stripped)
|
|
1049
1627
|
if m:
|
|
1050
1628
|
cost_estimate = m.group(0)
|
|
1051
1629
|
# Iteration count
|
|
1052
|
-
if
|
|
1053
|
-
m =
|
|
1630
|
+
if re.search(r'iterations?\s*[:=]\s*\d+', lower):
|
|
1631
|
+
m = re.search(r'iterations?\s*[:=]\s*(\d+)', lower)
|
|
1054
1632
|
if m:
|
|
1055
1633
|
iterations = int(m.group(1))
|
|
1056
1634
|
# Phase/step lines
|
|
1057
|
-
if
|
|
1635
|
+
if re.match(r'^\s*(phase|step)\s+\d', lower):
|
|
1058
1636
|
for phase_name in ("planning", "implementation", "testing", "review", "deployment"):
|
|
1059
|
-
if
|
|
1637
|
+
if re.search(rf'\b{phase_name}\b', lower) and phase_name not in phases:
|
|
1060
1638
|
phases.append(phase_name)
|
|
1061
1639
|
|
|
1062
1640
|
if not parsed and not phases:
|
|
@@ -1093,7 +1671,6 @@ async def share_session() -> JSONResponse:
|
|
|
1093
1671
|
None, lambda: _run_loki_cmd(["share"], timeout=60)
|
|
1094
1672
|
)
|
|
1095
1673
|
# Try to extract URL from output
|
|
1096
|
-
import re
|
|
1097
1674
|
url_match = re.search(r"https://gist\.github\.com/\S+", output)
|
|
1098
1675
|
url = url_match.group(0) if url_match else ""
|
|
1099
1676
|
return JSONResponse(content={
|
|
@@ -1163,7 +1740,6 @@ async def get_metrics() -> JSONResponse:
|
|
|
1163
1740
|
except (json.JSONDecodeError, ValueError):
|
|
1164
1741
|
pass
|
|
1165
1742
|
# Fallback: parse key metrics from text output
|
|
1166
|
-
import re
|
|
1167
1743
|
metrics: dict = {
|
|
1168
1744
|
"iterations": 0,
|
|
1169
1745
|
"quality_gate_pass_rate": 0.0,
|
|
@@ -1314,7 +1890,6 @@ async def get_sessions_history() -> JSONResponse:
|
|
|
1314
1890
|
@app.get("/api/sessions/{session_id}")
|
|
1315
1891
|
async def get_session_detail(session_id: str) -> JSONResponse:
|
|
1316
1892
|
"""Get details of a past session for read-only viewing."""
|
|
1317
|
-
import re
|
|
1318
1893
|
# Validate session_id format (prevent path traversal)
|
|
1319
1894
|
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
1320
1895
|
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
@@ -1376,7 +1951,6 @@ async def get_session_detail(session_id: str) -> JSONResponse:
|
|
|
1376
1951
|
@app.get("/api/sessions/{session_id}/file")
|
|
1377
1952
|
async def get_session_file(session_id: str, path: str = "") -> JSONResponse:
|
|
1378
1953
|
"""Get file content from a past session with path traversal protection."""
|
|
1379
|
-
import re
|
|
1380
1954
|
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id) or not path:
|
|
1381
1955
|
return JSONResponse(status_code=400, content={"error": "Invalid session ID or path"})
|
|
1382
1956
|
|
|
@@ -1418,7 +1992,6 @@ async def preview_session_file(session_id: str, file_path: str = "index.html") -
|
|
|
1418
1992
|
This enables live preview of built projects -- HTML files can load their
|
|
1419
1993
|
relative CSS, JS, and image assets correctly.
|
|
1420
1994
|
"""
|
|
1421
|
-
import re
|
|
1422
1995
|
import mimetypes
|
|
1423
1996
|
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
1424
1997
|
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
@@ -1456,7 +2029,6 @@ async def preview_session_file(session_id: str, file_path: str = "index.html") -
|
|
|
1456
2029
|
@app.put("/api/sessions/{session_id}/file")
|
|
1457
2030
|
async def save_session_file(session_id: str, req: FileWriteRequest) -> JSONResponse:
|
|
1458
2031
|
"""Save or update file content in a session's project directory."""
|
|
1459
|
-
import re
|
|
1460
2032
|
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
1461
2033
|
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
1462
2034
|
if not req.path:
|
|
@@ -1504,7 +2076,6 @@ async def save_session_file(session_id: str, req: FileWriteRequest) -> JSONRespo
|
|
|
1504
2076
|
@app.post("/api/sessions/{session_id}/file")
|
|
1505
2077
|
async def create_session_file(session_id: str, req: FileWriteRequest) -> JSONResponse:
|
|
1506
2078
|
"""Create a new file in a session's project directory."""
|
|
1507
|
-
import re
|
|
1508
2079
|
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
1509
2080
|
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
1510
2081
|
if not req.path:
|
|
@@ -1545,7 +2116,6 @@ async def create_session_file(session_id: str, req: FileWriteRequest) -> JSONRes
|
|
|
1545
2116
|
@app.delete("/api/sessions/{session_id}/file")
|
|
1546
2117
|
async def delete_session_file(session_id: str, req: FileDeleteRequest) -> JSONResponse:
|
|
1547
2118
|
"""Delete a file from a session's project directory."""
|
|
1548
|
-
import re
|
|
1549
2119
|
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
1550
2120
|
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
1551
2121
|
if not req.path:
|
|
@@ -1588,7 +2158,6 @@ async def delete_session_file(session_id: str, req: FileDeleteRequest) -> JSONRe
|
|
|
1588
2158
|
@app.post("/api/sessions/{session_id}/directory")
|
|
1589
2159
|
async def create_session_directory(session_id: str, req: DirectoryCreateRequest) -> JSONResponse:
|
|
1590
2160
|
"""Create a directory in a session's project directory."""
|
|
1591
|
-
import re
|
|
1592
2161
|
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
1593
2162
|
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
1594
2163
|
if not req.path:
|
|
@@ -1677,26 +2246,79 @@ async def chat_session(session_id: str, req: ChatRequest) -> JSONResponse:
|
|
|
1677
2246
|
if target is None:
|
|
1678
2247
|
return JSONResponse(status_code=404, content={"error": "Session not found"})
|
|
1679
2248
|
|
|
2249
|
+
# Clean up old completed tasks to prevent unbounded memory growth
|
|
2250
|
+
_cleanup_chat_tasks()
|
|
2251
|
+
|
|
2252
|
+
_cleanup_chat_tasks()
|
|
1680
2253
|
task = ChatTask()
|
|
1681
2254
|
_chat_tasks[task.id] = task
|
|
1682
2255
|
|
|
1683
2256
|
async def run_chat() -> None:
|
|
1684
|
-
|
|
2257
|
+
proc: Optional[subprocess.Popen] = None
|
|
2258
|
+
loki = _find_loki_cli()
|
|
2259
|
+
if loki is None:
|
|
2260
|
+
task.output_lines = ["loki CLI not found"]
|
|
2261
|
+
task.returncode = 1
|
|
2262
|
+
task.complete = True
|
|
2263
|
+
return
|
|
1685
2264
|
if req.mode == "quick":
|
|
1686
|
-
cmd_args = ["quick", req.message]
|
|
2265
|
+
cmd_args = [loki, "quick", req.message]
|
|
1687
2266
|
else:
|
|
1688
|
-
cmd_args = ["start", "--provider", "claude", str(target / "PRD.md")]
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
2267
|
+
cmd_args = [loki, "start", "--provider", "claude", str(target / "PRD.md")]
|
|
2268
|
+
try:
|
|
2269
|
+
proc = subprocess.Popen(
|
|
2270
|
+
cmd_args,
|
|
2271
|
+
stdout=subprocess.PIPE,
|
|
2272
|
+
stderr=subprocess.STDOUT,
|
|
2273
|
+
stdin=subprocess.DEVNULL,
|
|
2274
|
+
text=True,
|
|
2275
|
+
cwd=str(target),
|
|
2276
|
+
env={**os.environ},
|
|
2277
|
+
start_new_session=True,
|
|
2278
|
+
)
|
|
2279
|
+
task.process = proc
|
|
2280
|
+
loop = asyncio.get_running_loop()
|
|
2281
|
+
|
|
2282
|
+
def _read_lines() -> None:
|
|
2283
|
+
"""Read stdout line-by-line in a thread."""
|
|
2284
|
+
assert proc.stdout is not None
|
|
2285
|
+
for raw_line in proc.stdout:
|
|
2286
|
+
if task.cancelled:
|
|
2287
|
+
break
|
|
2288
|
+
task.output_lines.append(raw_line.rstrip("\n"))
|
|
2289
|
+
proc.stdout.close()
|
|
2290
|
+
|
|
2291
|
+
await asyncio.wait_for(
|
|
2292
|
+
loop.run_in_executor(None, _read_lines),
|
|
2293
|
+
timeout=300,
|
|
2294
|
+
)
|
|
2295
|
+
proc.wait(timeout=10)
|
|
2296
|
+
task.returncode = proc.returncode
|
|
2297
|
+
except asyncio.TimeoutError:
|
|
2298
|
+
if proc is not None:
|
|
2299
|
+
try:
|
|
2300
|
+
pgid = os.getpgid(proc.pid)
|
|
2301
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
2302
|
+
except (ProcessLookupError, OSError):
|
|
2303
|
+
proc.kill()
|
|
2304
|
+
proc.wait()
|
|
2305
|
+
task.output_lines.append("Command timed out after 5 minutes")
|
|
2306
|
+
task.returncode = 1
|
|
2307
|
+
except Exception as e:
|
|
2308
|
+
task.output_lines.append(str(e))
|
|
2309
|
+
task.returncode = 1
|
|
2310
|
+
if proc is not None and proc.poll() is None:
|
|
2311
|
+
try:
|
|
2312
|
+
pgid = os.getpgid(proc.pid)
|
|
2313
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
2314
|
+
except (ProcessLookupError, OSError):
|
|
2315
|
+
proc.kill()
|
|
2316
|
+
proc.wait()
|
|
1694
2317
|
# Detect changed files
|
|
1695
2318
|
try:
|
|
1696
|
-
|
|
1697
|
-
result = _sp.run(
|
|
2319
|
+
result = subprocess.run(
|
|
1698
2320
|
["git", "diff", "--name-only", "HEAD~1"],
|
|
1699
|
-
cwd=str(target), capture_output=True, text=True, timeout=10
|
|
2321
|
+
cwd=str(target), capture_output=True, text=True, timeout=10,
|
|
1700
2322
|
)
|
|
1701
2323
|
if result.returncode == 0:
|
|
1702
2324
|
task.files_changed = [f for f in result.stdout.strip().splitlines() if f]
|
|
@@ -1728,10 +2350,88 @@ async def get_chat_status(session_id: str, task_id: str) -> JSONResponse:
|
|
|
1728
2350
|
})
|
|
1729
2351
|
|
|
1730
2352
|
|
|
2353
|
+
@app.get("/api/sessions/{session_id}/chat/{task_id}/stream")
|
|
2354
|
+
async def stream_chat(session_id: str, task_id: str, request: Request) -> StreamingResponse:
|
|
2355
|
+
"""Stream chat task output as Server-Sent Events.
|
|
2356
|
+
|
|
2357
|
+
Sends incremental output lines as they arrive, 10x faster than polling.
|
|
2358
|
+
Falls back gracefully -- the polling endpoint remains available.
|
|
2359
|
+
"""
|
|
2360
|
+
task = _chat_tasks.get(task_id)
|
|
2361
|
+
_sse_headers = {
|
|
2362
|
+
"Cache-Control": "no-cache",
|
|
2363
|
+
"Connection": "keep-alive",
|
|
2364
|
+
"X-Accel-Buffering": "no",
|
|
2365
|
+
}
|
|
2366
|
+
if task is None:
|
|
2367
|
+
async def _not_found():
|
|
2368
|
+
yield f"event: error\ndata: {json.dumps({'error': 'Task not found'})}\n\n"
|
|
2369
|
+
return StreamingResponse(_not_found(), media_type="text/event-stream", headers=_sse_headers)
|
|
2370
|
+
|
|
2371
|
+
async def event_generator():
|
|
2372
|
+
last_line = 0
|
|
2373
|
+
while True:
|
|
2374
|
+
# Check if the client disconnected
|
|
2375
|
+
if await request.is_disconnected():
|
|
2376
|
+
break
|
|
2377
|
+
|
|
2378
|
+
# Send new lines since last check
|
|
2379
|
+
current_count = len(task.output_lines)
|
|
2380
|
+
if current_count > last_line:
|
|
2381
|
+
for line in task.output_lines[last_line:current_count]:
|
|
2382
|
+
yield f"event: output\ndata: {json.dumps({'line': line})}\n\n"
|
|
2383
|
+
last_line = current_count
|
|
2384
|
+
|
|
2385
|
+
# Check if complete -- flush any final lines first
|
|
2386
|
+
if task.complete:
|
|
2387
|
+
final_count = len(task.output_lines)
|
|
2388
|
+
if final_count > last_line:
|
|
2389
|
+
for line in task.output_lines[last_line:final_count]:
|
|
2390
|
+
yield f"event: output\ndata: {json.dumps({'line': line})}\n\n"
|
|
2391
|
+
yield f"event: complete\ndata: {json.dumps({'returncode': task.returncode, 'files_changed': task.files_changed})}\n\n"
|
|
2392
|
+
break
|
|
2393
|
+
|
|
2394
|
+
await asyncio.sleep(0.1) # 100ms -- 20x faster than 2s polling
|
|
2395
|
+
|
|
2396
|
+
return StreamingResponse(
|
|
2397
|
+
event_generator(),
|
|
2398
|
+
media_type="text/event-stream",
|
|
2399
|
+
headers=_sse_headers,
|
|
2400
|
+
)
|
|
2401
|
+
|
|
2402
|
+
|
|
2403
|
+
@app.post("/api/sessions/{session_id}/chat/{task_id}/cancel")
|
|
2404
|
+
async def cancel_chat(session_id: str, task_id: str) -> JSONResponse:
|
|
2405
|
+
"""Cancel a running chat task."""
|
|
2406
|
+
task = _chat_tasks.get(task_id)
|
|
2407
|
+
if task is None:
|
|
2408
|
+
return JSONResponse(status_code=404, content={"error": "Task not found"})
|
|
2409
|
+
if task.complete:
|
|
2410
|
+
return JSONResponse(content={"cancelled": False, "reason": "Task already complete"})
|
|
2411
|
+
task.cancelled = True
|
|
2412
|
+
if task.process and task.process.poll() is None:
|
|
2413
|
+
try:
|
|
2414
|
+
pgid = os.getpgid(task.process.pid)
|
|
2415
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
2416
|
+
task.process.wait(timeout=3)
|
|
2417
|
+
except (ProcessLookupError, OSError):
|
|
2418
|
+
pass
|
|
2419
|
+
if task.process.poll() is None:
|
|
2420
|
+
try:
|
|
2421
|
+
pgid = os.getpgid(task.process.pid)
|
|
2422
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
2423
|
+
except (ProcessLookupError, OSError):
|
|
2424
|
+
task.process.kill()
|
|
2425
|
+
task.process.wait(timeout=5)
|
|
2426
|
+
task.output_lines.append("[cancelled by user]")
|
|
2427
|
+
task.returncode = 1
|
|
2428
|
+
task.complete = True
|
|
2429
|
+
return JSONResponse(content={"cancelled": True})
|
|
2430
|
+
|
|
2431
|
+
|
|
1731
2432
|
@app.post("/api/sessions/{session_id}/review")
|
|
1732
2433
|
async def review_session(session_id: str) -> JSONResponse:
|
|
1733
2434
|
"""Run loki review on a project."""
|
|
1734
|
-
import re
|
|
1735
2435
|
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
1736
2436
|
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
1737
2437
|
target = _find_session_dir(session_id)
|
|
@@ -1746,7 +2446,6 @@ async def review_session(session_id: str) -> JSONResponse:
|
|
|
1746
2446
|
@app.post("/api/sessions/{session_id}/test")
|
|
1747
2447
|
async def test_session(session_id: str) -> JSONResponse:
|
|
1748
2448
|
"""Run loki test on a project."""
|
|
1749
|
-
import re
|
|
1750
2449
|
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
1751
2450
|
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
1752
2451
|
target = _find_session_dir(session_id)
|
|
@@ -1761,7 +2460,6 @@ async def test_session(session_id: str) -> JSONResponse:
|
|
|
1761
2460
|
@app.post("/api/sessions/{session_id}/explain")
|
|
1762
2461
|
async def explain_session(session_id: str) -> JSONResponse:
|
|
1763
2462
|
"""Run loki explain on a project."""
|
|
1764
|
-
import re
|
|
1765
2463
|
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
1766
2464
|
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
1767
2465
|
target = _find_session_dir(session_id)
|
|
@@ -1776,7 +2474,6 @@ async def explain_session(session_id: str) -> JSONResponse:
|
|
|
1776
2474
|
@app.post("/api/sessions/{session_id}/export")
|
|
1777
2475
|
async def export_session(session_id: str) -> JSONResponse:
|
|
1778
2476
|
"""Run loki export json on a project."""
|
|
1779
|
-
import re
|
|
1780
2477
|
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
1781
2478
|
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
1782
2479
|
target = _find_session_dir(session_id)
|
|
@@ -1949,6 +2646,400 @@ async def get_preview_info(session_id: str) -> JSONResponse:
|
|
|
1949
2646
|
return JSONResponse(content=info)
|
|
1950
2647
|
|
|
1951
2648
|
|
|
2649
|
+
# ---------------------------------------------------------------------------
|
|
2650
|
+
# Dev server management endpoints
|
|
2651
|
+
# ---------------------------------------------------------------------------
|
|
2652
|
+
|
|
2653
|
+
|
|
2654
|
+
@app.post("/api/sessions/{session_id}/devserver/start")
|
|
2655
|
+
async def start_devserver(session_id: str, req: DevServerStartRequest) -> JSONResponse:
|
|
2656
|
+
"""Start a dev server for a session's project."""
|
|
2657
|
+
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
2658
|
+
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
2659
|
+
target = _find_session_dir(session_id)
|
|
2660
|
+
if target is None:
|
|
2661
|
+
return JSONResponse(status_code=404, content={"error": "Session not found"})
|
|
2662
|
+
result = await dev_server_manager.start(session_id, str(target), req.command)
|
|
2663
|
+
status_code = 200 if result.get("status") != "error" else 500
|
|
2664
|
+
return JSONResponse(content=result, status_code=status_code)
|
|
2665
|
+
|
|
2666
|
+
|
|
2667
|
+
@app.post("/api/sessions/{session_id}/devserver/stop")
|
|
2668
|
+
async def stop_devserver(session_id: str) -> JSONResponse:
|
|
2669
|
+
"""Stop the dev server for a session."""
|
|
2670
|
+
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
2671
|
+
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
2672
|
+
result = await dev_server_manager.stop(session_id)
|
|
2673
|
+
return JSONResponse(content=result)
|
|
2674
|
+
|
|
2675
|
+
|
|
2676
|
+
@app.get("/api/sessions/{session_id}/devserver/status")
|
|
2677
|
+
async def get_devserver_status(session_id: str) -> JSONResponse:
|
|
2678
|
+
"""Get dev server status for a session."""
|
|
2679
|
+
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
2680
|
+
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
2681
|
+
result = await dev_server_manager.status(session_id)
|
|
2682
|
+
return JSONResponse(content=result)
|
|
2683
|
+
|
|
2684
|
+
|
|
2685
|
+
# ---------------------------------------------------------------------------
|
|
2686
|
+
# HTTP Proxy for dev server preview
|
|
2687
|
+
# ---------------------------------------------------------------------------
|
|
2688
|
+
|
|
2689
|
+
|
|
2690
|
+
@app.api_route("/proxy/{session_id}/{path:path}",
|
|
2691
|
+
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
|
2692
|
+
async def proxy_to_devserver(session_id: str, path: str, request: Request):
|
|
2693
|
+
"""Proxy requests to the dev server running for this session.
|
|
2694
|
+
|
|
2695
|
+
Uses streaming to handle large responses without buffering them entirely
|
|
2696
|
+
in memory (important for JS bundles, images, etc.).
|
|
2697
|
+
"""
|
|
2698
|
+
import httpx
|
|
2699
|
+
|
|
2700
|
+
server = dev_server_manager.servers.get(session_id)
|
|
2701
|
+
if not server or server["status"] != "running" or server.get("port") is None:
|
|
2702
|
+
return JSONResponse(
|
|
2703
|
+
{"error": "Dev server not running", "hint": "Start the dev server first"},
|
|
2704
|
+
status_code=503,
|
|
2705
|
+
)
|
|
2706
|
+
|
|
2707
|
+
target_port = server["port"]
|
|
2708
|
+
target_url = f"http://127.0.0.1:{target_port}/{path}"
|
|
2709
|
+
if request.url.query:
|
|
2710
|
+
target_url += f"?{request.url.query}"
|
|
2711
|
+
|
|
2712
|
+
# Build headers to forward (skip hop-by-hop headers)
|
|
2713
|
+
skip_headers = {"host", "connection", "keep-alive", "transfer-encoding",
|
|
2714
|
+
"te", "trailer", "upgrade"}
|
|
2715
|
+
fwd_headers = {
|
|
2716
|
+
k: v for k, v in request.headers.items()
|
|
2717
|
+
if k.lower() not in skip_headers
|
|
2718
|
+
}
|
|
2719
|
+
fwd_headers["host"] = f"127.0.0.1:{target_port}"
|
|
2720
|
+
|
|
2721
|
+
body = await request.body()
|
|
2722
|
+
|
|
2723
|
+
# Use a client that is NOT used as a context manager so we can stream
|
|
2724
|
+
# the response body without closing the connection prematurely.
|
|
2725
|
+
client = httpx.AsyncClient(timeout=60.0, follow_redirects=False)
|
|
2726
|
+
try:
|
|
2727
|
+
resp = await client.send(
|
|
2728
|
+
client.build_request(
|
|
2729
|
+
method=request.method,
|
|
2730
|
+
url=target_url,
|
|
2731
|
+
headers=fwd_headers,
|
|
2732
|
+
content=body if body else None,
|
|
2733
|
+
),
|
|
2734
|
+
stream=True,
|
|
2735
|
+
)
|
|
2736
|
+
except httpx.ConnectError:
|
|
2737
|
+
await client.aclose()
|
|
2738
|
+
return JSONResponse(
|
|
2739
|
+
{"error": "Cannot connect to dev server", "port": target_port},
|
|
2740
|
+
status_code=502,
|
|
2741
|
+
)
|
|
2742
|
+
except httpx.TimeoutException:
|
|
2743
|
+
await client.aclose()
|
|
2744
|
+
return JSONResponse(
|
|
2745
|
+
{"error": "Dev server request timed out"},
|
|
2746
|
+
status_code=504,
|
|
2747
|
+
)
|
|
2748
|
+
except Exception as e:
|
|
2749
|
+
await client.aclose()
|
|
2750
|
+
return JSONResponse(
|
|
2751
|
+
{"error": f"Proxy error: {e}"},
|
|
2752
|
+
status_code=502,
|
|
2753
|
+
)
|
|
2754
|
+
|
|
2755
|
+
# Build response headers, passing through relevant ones
|
|
2756
|
+
resp_skip = {"transfer-encoding", "connection", "keep-alive", "content-encoding"}
|
|
2757
|
+
resp_headers = {
|
|
2758
|
+
k: v for k, v in resp.headers.items()
|
|
2759
|
+
if k.lower() not in resp_skip
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
async def stream_body():
|
|
2763
|
+
try:
|
|
2764
|
+
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
|
2765
|
+
yield chunk
|
|
2766
|
+
finally:
|
|
2767
|
+
await resp.aclose()
|
|
2768
|
+
await client.aclose()
|
|
2769
|
+
|
|
2770
|
+
return StreamingResponse(
|
|
2771
|
+
content=stream_body(),
|
|
2772
|
+
status_code=resp.status_code,
|
|
2773
|
+
headers=resp_headers,
|
|
2774
|
+
)
|
|
2775
|
+
|
|
2776
|
+
|
|
2777
|
+
@app.websocket("/proxy/{session_id}/{path:path}")
|
|
2778
|
+
async def proxy_websocket(websocket: WebSocket, session_id: str, path: str):
|
|
2779
|
+
"""Proxy WebSocket connections for HMR (Vite, webpack, etc.).
|
|
2780
|
+
|
|
2781
|
+
Handles any WS path under /proxy/{session_id}/ so that Vite's
|
|
2782
|
+
/__vite_hmr, webpack's /ws, and other HMR paths all work.
|
|
2783
|
+
"""
|
|
2784
|
+
import websockets
|
|
2785
|
+
|
|
2786
|
+
server = dev_server_manager.servers.get(session_id)
|
|
2787
|
+
if not server or server["status"] != "running" or server.get("port") is None:
|
|
2788
|
+
await websocket.close(code=1008, reason="Dev server not running")
|
|
2789
|
+
return
|
|
2790
|
+
|
|
2791
|
+
target_port = server["port"]
|
|
2792
|
+
# Forward the exact sub-path to the upstream dev server
|
|
2793
|
+
ws_url = f"ws://127.0.0.1:{target_port}/{path}"
|
|
2794
|
+
|
|
2795
|
+
await websocket.accept()
|
|
2796
|
+
|
|
2797
|
+
try:
|
|
2798
|
+
async with websockets.connect(ws_url) as upstream:
|
|
2799
|
+
async def client_to_upstream():
|
|
2800
|
+
try:
|
|
2801
|
+
while True:
|
|
2802
|
+
data = await websocket.receive_text()
|
|
2803
|
+
await upstream.send(data)
|
|
2804
|
+
except (WebSocketDisconnect, Exception):
|
|
2805
|
+
pass
|
|
2806
|
+
|
|
2807
|
+
async def upstream_to_client():
|
|
2808
|
+
try:
|
|
2809
|
+
async for msg in upstream:
|
|
2810
|
+
if isinstance(msg, str):
|
|
2811
|
+
await websocket.send_text(msg)
|
|
2812
|
+
elif isinstance(msg, bytes):
|
|
2813
|
+
await websocket.send_bytes(msg)
|
|
2814
|
+
except Exception:
|
|
2815
|
+
pass
|
|
2816
|
+
|
|
2817
|
+
await asyncio.gather(
|
|
2818
|
+
client_to_upstream(),
|
|
2819
|
+
upstream_to_client(),
|
|
2820
|
+
return_exceptions=True,
|
|
2821
|
+
)
|
|
2822
|
+
except Exception:
|
|
2823
|
+
pass
|
|
2824
|
+
finally:
|
|
2825
|
+
try:
|
|
2826
|
+
await websocket.close()
|
|
2827
|
+
except Exception:
|
|
2828
|
+
pass
|
|
2829
|
+
|
|
2830
|
+
|
|
2831
|
+
# ---------------------------------------------------------------------------
|
|
2832
|
+
# Authentication middleware
|
|
2833
|
+
# ---------------------------------------------------------------------------
|
|
2834
|
+
|
|
2835
|
+
|
|
2836
|
+
@app.middleware("http")
|
|
2837
|
+
async def auth_middleware(request: Request, call_next):
|
|
2838
|
+
"""Enforce JWT auth when database is configured. Skip for public paths."""
|
|
2839
|
+
path = request.url.path
|
|
2840
|
+
skip_auth_prefixes = ["/health", "/api/auth/", "/ws", "/proxy/"]
|
|
2841
|
+
if any(path.startswith(p) for p in skip_auth_prefixes) or not path.startswith("/api/"):
|
|
2842
|
+
return await call_next(request)
|
|
2843
|
+
|
|
2844
|
+
# If no DB configured, skip auth (local mode)
|
|
2845
|
+
try:
|
|
2846
|
+
from models import async_session_factory
|
|
2847
|
+
if async_session_factory is None:
|
|
2848
|
+
return await call_next(request)
|
|
2849
|
+
except ImportError:
|
|
2850
|
+
return await call_next(request)
|
|
2851
|
+
|
|
2852
|
+
# Verify JWT
|
|
2853
|
+
auth_header = request.headers.get("Authorization")
|
|
2854
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
|
2855
|
+
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
|
2856
|
+
|
|
2857
|
+
try:
|
|
2858
|
+
from auth import verify_token
|
|
2859
|
+
except ImportError:
|
|
2860
|
+
# Auth module not installed but database IS configured -- block the request
|
|
2861
|
+
logger.warning("Auth module not available but database is configured -- blocking request")
|
|
2862
|
+
return JSONResponse({"error": "Server authentication misconfigured"}, status_code=500)
|
|
2863
|
+
|
|
2864
|
+
token = auth_header.split(" ", 1)[1]
|
|
2865
|
+
payload = verify_token(token)
|
|
2866
|
+
if not payload:
|
|
2867
|
+
return JSONResponse({"error": "Invalid or expired token"}, status_code=401)
|
|
2868
|
+
request.state.user = payload
|
|
2869
|
+
|
|
2870
|
+
return await call_next(request)
|
|
2871
|
+
|
|
2872
|
+
|
|
2873
|
+
# ---------------------------------------------------------------------------
|
|
2874
|
+
# Auth endpoints
|
|
2875
|
+
# ---------------------------------------------------------------------------
|
|
2876
|
+
|
|
2877
|
+
|
|
2878
|
+
@app.get("/api/auth/me")
|
|
2879
|
+
async def get_me(request: Request) -> JSONResponse:
|
|
2880
|
+
"""Get current user info. Returns local_mode=True when no auth configured."""
|
|
2881
|
+
try:
|
|
2882
|
+
from models import async_session_factory
|
|
2883
|
+
if async_session_factory is None:
|
|
2884
|
+
return JSONResponse(content={"authenticated": False, "local_mode": True})
|
|
2885
|
+
except ImportError:
|
|
2886
|
+
return JSONResponse(content={"authenticated": False, "local_mode": True})
|
|
2887
|
+
|
|
2888
|
+
user = getattr(request.state, "user", None)
|
|
2889
|
+
if user is None:
|
|
2890
|
+
return JSONResponse(content={"authenticated": False, "local_mode": False})
|
|
2891
|
+
return JSONResponse(content={"authenticated": True, **user})
|
|
2892
|
+
|
|
2893
|
+
|
|
2894
|
+
@app.get("/api/auth/github/url")
|
|
2895
|
+
async def github_auth_url() -> JSONResponse:
|
|
2896
|
+
"""Get GitHub OAuth authorization URL with CSRF state parameter."""
|
|
2897
|
+
try:
|
|
2898
|
+
from auth import GITHUB_CLIENT_ID, generate_oauth_state
|
|
2899
|
+
except ImportError:
|
|
2900
|
+
return JSONResponse(status_code=501, content={"error": "Auth module not available"})
|
|
2901
|
+
if not GITHUB_CLIENT_ID:
|
|
2902
|
+
return JSONResponse(status_code=501, content={"error": "GitHub OAuth not configured"})
|
|
2903
|
+
state = generate_oauth_state()
|
|
2904
|
+
url = f"https://github.com/login/oauth/authorize?client_id={GITHUB_CLIENT_ID}&scope=user:email&state={state}"
|
|
2905
|
+
return JSONResponse(content={"url": url})
|
|
2906
|
+
|
|
2907
|
+
|
|
2908
|
+
@app.post("/api/auth/github/callback")
|
|
2909
|
+
async def github_callback(body: dict = Body(...)) -> JSONResponse:
|
|
2910
|
+
"""Handle GitHub OAuth callback -- exchange code for JWT."""
|
|
2911
|
+
try:
|
|
2912
|
+
from auth import create_access_token, github_oauth_callback, validate_oauth_state
|
|
2913
|
+
from models import async_session_factory, User
|
|
2914
|
+
except ImportError:
|
|
2915
|
+
return JSONResponse(status_code=501, content={"error": "Auth module not available"})
|
|
2916
|
+
|
|
2917
|
+
code = body.get("code")
|
|
2918
|
+
state = body.get("state")
|
|
2919
|
+
if not code:
|
|
2920
|
+
return JSONResponse(status_code=400, content={"error": "Missing code parameter"})
|
|
2921
|
+
if not validate_oauth_state(state):
|
|
2922
|
+
return JSONResponse(status_code=403, content={"error": "Invalid or expired OAuth state (CSRF check failed)"})
|
|
2923
|
+
|
|
2924
|
+
try:
|
|
2925
|
+
user_info = await github_oauth_callback(code)
|
|
2926
|
+
except HTTPException:
|
|
2927
|
+
raise
|
|
2928
|
+
except Exception as exc:
|
|
2929
|
+
logger.error("GitHub OAuth callback failed: %s", exc)
|
|
2930
|
+
return JSONResponse(status_code=502, content={"error": "GitHub authentication failed"})
|
|
2931
|
+
|
|
2932
|
+
# Create or update user in DB if database is configured
|
|
2933
|
+
if async_session_factory:
|
|
2934
|
+
from sqlalchemy import select
|
|
2935
|
+
async with async_session_factory() as db:
|
|
2936
|
+
result = await db.execute(
|
|
2937
|
+
select(User).where(User.email == user_info["email"])
|
|
2938
|
+
)
|
|
2939
|
+
db_user = result.scalar_one_or_none()
|
|
2940
|
+
if db_user is None:
|
|
2941
|
+
db_user = User(
|
|
2942
|
+
email=user_info["email"],
|
|
2943
|
+
name=user_info["name"],
|
|
2944
|
+
avatar_url=user_info.get("avatar_url"),
|
|
2945
|
+
provider="github",
|
|
2946
|
+
provider_id=user_info["provider_id"],
|
|
2947
|
+
)
|
|
2948
|
+
db.add(db_user)
|
|
2949
|
+
else:
|
|
2950
|
+
db_user.name = user_info["name"]
|
|
2951
|
+
db_user.avatar_url = user_info.get("avatar_url")
|
|
2952
|
+
db_user.last_login = datetime.utcnow()
|
|
2953
|
+
await db.commit()
|
|
2954
|
+
|
|
2955
|
+
token = create_access_token({
|
|
2956
|
+
"sub": user_info["email"],
|
|
2957
|
+
"name": user_info["name"],
|
|
2958
|
+
"avatar": user_info.get("avatar_url", ""),
|
|
2959
|
+
})
|
|
2960
|
+
return JSONResponse(content={"token": token, "user": user_info})
|
|
2961
|
+
|
|
2962
|
+
|
|
2963
|
+
@app.get("/api/auth/google/url")
|
|
2964
|
+
async def google_auth_url() -> JSONResponse:
|
|
2965
|
+
"""Get Google OAuth authorization URL with CSRF state parameter."""
|
|
2966
|
+
try:
|
|
2967
|
+
from auth import GOOGLE_CLIENT_ID, generate_oauth_state
|
|
2968
|
+
except ImportError:
|
|
2969
|
+
return JSONResponse(status_code=501, content={"error": "Auth module not available"})
|
|
2970
|
+
if not GOOGLE_CLIENT_ID:
|
|
2971
|
+
return JSONResponse(status_code=501, content={"error": "Google OAuth not configured"})
|
|
2972
|
+
redirect_uri = os.environ.get("GOOGLE_REDIRECT_URI", f"http://localhost:{PORT}/api/auth/google/callback")
|
|
2973
|
+
state = generate_oauth_state()
|
|
2974
|
+
url = (
|
|
2975
|
+
f"https://accounts.google.com/o/oauth2/v2/auth"
|
|
2976
|
+
f"?client_id={GOOGLE_CLIENT_ID}"
|
|
2977
|
+
f"&redirect_uri={redirect_uri}"
|
|
2978
|
+
f"&response_type=code"
|
|
2979
|
+
f"&scope=openid%20email%20profile"
|
|
2980
|
+
f"&state={state}"
|
|
2981
|
+
)
|
|
2982
|
+
return JSONResponse(content={"url": url})
|
|
2983
|
+
|
|
2984
|
+
|
|
2985
|
+
@app.post("/api/auth/google/callback")
|
|
2986
|
+
async def google_callback(body: dict = Body(...)) -> JSONResponse:
|
|
2987
|
+
"""Handle Google OAuth callback -- exchange code for JWT."""
|
|
2988
|
+
try:
|
|
2989
|
+
from auth import create_access_token, google_oauth_callback, validate_oauth_state
|
|
2990
|
+
from models import async_session_factory, User
|
|
2991
|
+
except ImportError:
|
|
2992
|
+
return JSONResponse(status_code=501, content={"error": "Auth module not available"})
|
|
2993
|
+
|
|
2994
|
+
code = body.get("code")
|
|
2995
|
+
state = body.get("state")
|
|
2996
|
+
if not code:
|
|
2997
|
+
return JSONResponse(status_code=400, content={"error": "Missing code parameter"})
|
|
2998
|
+
if not validate_oauth_state(state):
|
|
2999
|
+
return JSONResponse(status_code=403, content={"error": "Invalid or expired OAuth state (CSRF check failed)"})
|
|
3000
|
+
|
|
3001
|
+
# Use server-controlled redirect_uri -- never trust client-supplied value
|
|
3002
|
+
redirect_uri = os.environ.get("GOOGLE_REDIRECT_URI", f"http://localhost:{PORT}/api/auth/google/callback")
|
|
3003
|
+
|
|
3004
|
+
try:
|
|
3005
|
+
user_info = await google_oauth_callback(code, redirect_uri)
|
|
3006
|
+
except HTTPException:
|
|
3007
|
+
raise
|
|
3008
|
+
except Exception as exc:
|
|
3009
|
+
logger.error("Google OAuth callback failed: %s", exc)
|
|
3010
|
+
return JSONResponse(status_code=502, content={"error": "Google authentication failed"})
|
|
3011
|
+
|
|
3012
|
+
# Create or update user in DB if database is configured
|
|
3013
|
+
if async_session_factory:
|
|
3014
|
+
from sqlalchemy import select
|
|
3015
|
+
async with async_session_factory() as db:
|
|
3016
|
+
result = await db.execute(
|
|
3017
|
+
select(User).where(User.email == user_info["email"])
|
|
3018
|
+
)
|
|
3019
|
+
db_user = result.scalar_one_or_none()
|
|
3020
|
+
if db_user is None:
|
|
3021
|
+
db_user = User(
|
|
3022
|
+
email=user_info["email"],
|
|
3023
|
+
name=user_info["name"],
|
|
3024
|
+
avatar_url=user_info.get("avatar_url"),
|
|
3025
|
+
provider="google",
|
|
3026
|
+
provider_id=user_info["provider_id"],
|
|
3027
|
+
)
|
|
3028
|
+
db.add(db_user)
|
|
3029
|
+
else:
|
|
3030
|
+
db_user.name = user_info["name"]
|
|
3031
|
+
db_user.avatar_url = user_info.get("avatar_url")
|
|
3032
|
+
db_user.last_login = datetime.utcnow()
|
|
3033
|
+
await db.commit()
|
|
3034
|
+
|
|
3035
|
+
token = create_access_token({
|
|
3036
|
+
"sub": user_info["email"],
|
|
3037
|
+
"name": user_info["name"],
|
|
3038
|
+
"avatar": user_info.get("avatar_url", ""),
|
|
3039
|
+
})
|
|
3040
|
+
return JSONResponse(content={"token": token, "user": user_info})
|
|
3041
|
+
|
|
3042
|
+
|
|
1952
3043
|
# ---------------------------------------------------------------------------
|
|
1953
3044
|
# Health check
|
|
1954
3045
|
# ---------------------------------------------------------------------------
|
|
@@ -2081,6 +3172,10 @@ async def websocket_endpoint(ws: WebSocket) -> None:
|
|
|
2081
3172
|
"data": {"running": session.running, "provider": session.provider},
|
|
2082
3173
|
}))
|
|
2083
3174
|
|
|
3175
|
+
# Ensure file watcher is running if session is active (handles page reloads)
|
|
3176
|
+
if session.running and session.project_dir and "session" not in file_watcher._observers:
|
|
3177
|
+
file_watcher.start("session", session.project_dir, _broadcast, asyncio.get_running_loop())
|
|
3178
|
+
|
|
2084
3179
|
# Send recent log lines as backfill
|
|
2085
3180
|
for line in session.log_lines[-100:]:
|
|
2086
3181
|
await ws.send_text(json.dumps({
|
|
@@ -2126,6 +3221,204 @@ async def websocket_endpoint(ws: WebSocket) -> None:
|
|
|
2126
3221
|
session.ws_clients.discard(ws)
|
|
2127
3222
|
|
|
2128
3223
|
|
|
3224
|
+
# ---------------------------------------------------------------------------
|
|
3225
|
+
|
|
3226
|
+
# ---------------------------------------------------------------------------
|
|
3227
|
+
# Terminal PTY WebSocket (interactive shell per session)
|
|
3228
|
+
# ---------------------------------------------------------------------------
|
|
3229
|
+
|
|
3230
|
+
# Track active WebSocket connections per session for multi-tab awareness
|
|
3231
|
+
_terminal_ws_clients: Dict[str, set] = {}
|
|
3232
|
+
|
|
3233
|
+
|
|
3234
|
+
@app.websocket("/ws/terminal/{session_id}")
|
|
3235
|
+
async def terminal_websocket(ws: WebSocket, session_id: str) -> None:
|
|
3236
|
+
"""Interactive terminal via PTY. Requires pexpect.
|
|
3237
|
+
|
|
3238
|
+
Reuses an existing PTY if one is already alive for this session (e.g. on
|
|
3239
|
+
reconnect or second browser tab). Only kills the PTY when the *last*
|
|
3240
|
+
WebSocket client for this session disconnects.
|
|
3241
|
+
"""
|
|
3242
|
+
await ws.accept()
|
|
3243
|
+
|
|
3244
|
+
if not HAS_PEXPECT:
|
|
3245
|
+
await ws.send_text(json.dumps({
|
|
3246
|
+
"type": "output",
|
|
3247
|
+
"data": "\r\n[Error] pexpect is not installed. "
|
|
3248
|
+
"Run: pip install pexpect\r\n",
|
|
3249
|
+
}))
|
|
3250
|
+
await ws.close()
|
|
3251
|
+
return
|
|
3252
|
+
|
|
3253
|
+
# ---- Reuse or spawn PTY ------------------------------------------------
|
|
3254
|
+
pty = _terminal_ptys.get(session_id)
|
|
3255
|
+
spawned_new = False
|
|
3256
|
+
if pty is None or not pty.isalive():
|
|
3257
|
+
# Determine working directory
|
|
3258
|
+
cwd = str(Path.home())
|
|
3259
|
+
session_dir = _find_session_dir(session_id)
|
|
3260
|
+
if session_dir and session_dir.is_dir():
|
|
3261
|
+
cwd = str(session_dir)
|
|
3262
|
+
elif session.project_dir and Path(session.project_dir).is_dir():
|
|
3263
|
+
cwd = session.project_dir
|
|
3264
|
+
|
|
3265
|
+
# Build environment with secrets injected
|
|
3266
|
+
env = os.environ.copy()
|
|
3267
|
+
env["TERM"] = "xterm-256color"
|
|
3268
|
+
try:
|
|
3269
|
+
secrets = _load_secrets()
|
|
3270
|
+
env.update(secrets)
|
|
3271
|
+
except Exception:
|
|
3272
|
+
pass
|
|
3273
|
+
|
|
3274
|
+
# Prefer user configured shell, fall back to /bin/bash
|
|
3275
|
+
user_shell = os.environ.get("SHELL", "/bin/bash")
|
|
3276
|
+
if not os.path.isfile(user_shell):
|
|
3277
|
+
user_shell = "/bin/bash"
|
|
3278
|
+
|
|
3279
|
+
try:
|
|
3280
|
+
pty = pexpect.spawn(
|
|
3281
|
+
user_shell,
|
|
3282
|
+
args=["--login"],
|
|
3283
|
+
encoding="utf-8",
|
|
3284
|
+
codec_errors="replace",
|
|
3285
|
+
cwd=cwd,
|
|
3286
|
+
env=env,
|
|
3287
|
+
dimensions=(24, 80),
|
|
3288
|
+
)
|
|
3289
|
+
pty.setecho(True)
|
|
3290
|
+
spawned_new = True
|
|
3291
|
+
except Exception as exc:
|
|
3292
|
+
await ws.send_text(json.dumps({
|
|
3293
|
+
"type": "output",
|
|
3294
|
+
"data": f"\r\n[Error] Failed to spawn terminal: {exc}\r\n",
|
|
3295
|
+
}))
|
|
3296
|
+
await ws.close()
|
|
3297
|
+
return
|
|
3298
|
+
|
|
3299
|
+
_terminal_ptys[session_id] = pty
|
|
3300
|
+
|
|
3301
|
+
# ---- Track this WebSocket client ----------------------------------------
|
|
3302
|
+
if session_id not in _terminal_ws_clients:
|
|
3303
|
+
_terminal_ws_clients[session_id] = set()
|
|
3304
|
+
ws_id = id(ws)
|
|
3305
|
+
_terminal_ws_clients[session_id].add(ws_id)
|
|
3306
|
+
|
|
3307
|
+
if not spawned_new:
|
|
3308
|
+
try:
|
|
3309
|
+
await ws.send_text(json.dumps({
|
|
3310
|
+
"type": "output",
|
|
3311
|
+
"data": "\r\n\x1b[32m-- Reconnected to existing terminal session --\x1b[0m\r\n",
|
|
3312
|
+
}))
|
|
3313
|
+
except Exception:
|
|
3314
|
+
pass
|
|
3315
|
+
|
|
3316
|
+
# ---- Background task: read PTY output and forward to WebSocket ----------
|
|
3317
|
+
async def read_pty_output() -> None:
|
|
3318
|
+
loop = asyncio.get_event_loop()
|
|
3319
|
+
while True:
|
|
3320
|
+
try:
|
|
3321
|
+
data = await loop.run_in_executor(None, _pty_read, pty)
|
|
3322
|
+
if data is None:
|
|
3323
|
+
# PTY closed / EOF
|
|
3324
|
+
try:
|
|
3325
|
+
await ws.send_text(json.dumps({
|
|
3326
|
+
"type": "output",
|
|
3327
|
+
"data": "\r\n[Terminal session ended]\r\n",
|
|
3328
|
+
}))
|
|
3329
|
+
except Exception:
|
|
3330
|
+
pass
|
|
3331
|
+
break
|
|
3332
|
+
if data:
|
|
3333
|
+
await ws.send_text(json.dumps({
|
|
3334
|
+
"type": "output",
|
|
3335
|
+
"data": data,
|
|
3336
|
+
}))
|
|
3337
|
+
except asyncio.CancelledError:
|
|
3338
|
+
break
|
|
3339
|
+
except Exception:
|
|
3340
|
+
break
|
|
3341
|
+
|
|
3342
|
+
reader_task = asyncio.create_task(read_pty_output())
|
|
3343
|
+
|
|
3344
|
+
try:
|
|
3345
|
+
while True:
|
|
3346
|
+
raw = await ws.receive_text()
|
|
3347
|
+
try:
|
|
3348
|
+
msg = json.loads(raw)
|
|
3349
|
+
except json.JSONDecodeError:
|
|
3350
|
+
continue
|
|
3351
|
+
|
|
3352
|
+
msg_type = msg.get("type", "")
|
|
3353
|
+
|
|
3354
|
+
if msg_type == "input":
|
|
3355
|
+
data = msg.get("data", "")
|
|
3356
|
+
if data and pty.isalive():
|
|
3357
|
+
pty.send(data)
|
|
3358
|
+
|
|
3359
|
+
elif msg_type == "resize":
|
|
3360
|
+
cols = msg.get("cols", 80)
|
|
3361
|
+
rows = msg.get("rows", 24)
|
|
3362
|
+
if pty.isalive():
|
|
3363
|
+
pty.setwinsize(rows, cols)
|
|
3364
|
+
|
|
3365
|
+
except WebSocketDisconnect:
|
|
3366
|
+
pass
|
|
3367
|
+
except Exception:
|
|
3368
|
+
pass
|
|
3369
|
+
finally:
|
|
3370
|
+
reader_task.cancel()
|
|
3371
|
+
try:
|
|
3372
|
+
await reader_task
|
|
3373
|
+
except (asyncio.CancelledError, Exception):
|
|
3374
|
+
pass
|
|
3375
|
+
|
|
3376
|
+
# Untrack this client
|
|
3377
|
+
clients = _terminal_ws_clients.get(session_id)
|
|
3378
|
+
if clients:
|
|
3379
|
+
clients.discard(ws_id)
|
|
3380
|
+
|
|
3381
|
+
# Only kill PTY when the last client disconnects
|
|
3382
|
+
remaining = _terminal_ws_clients.get(session_id)
|
|
3383
|
+
if not remaining:
|
|
3384
|
+
_terminal_ws_clients.pop(session_id, None)
|
|
3385
|
+
if pty.isalive():
|
|
3386
|
+
pty.close(force=True)
|
|
3387
|
+
_terminal_ptys.pop(session_id, None)
|
|
3388
|
+
|
|
3389
|
+
|
|
3390
|
+
def _pty_read(pty: "pexpect.spawn") -> Optional[str]:
|
|
3391
|
+
"""Blocking read from PTY with batching.
|
|
3392
|
+
|
|
3393
|
+
Uses a longer timeout (0.5s) to avoid busy-looping when idle.
|
|
3394
|
+
When data IS available, greedily reads more to batch output
|
|
3395
|
+
(up to 32KB per batch to keep latency reasonable).
|
|
3396
|
+
Returns None on EOF.
|
|
3397
|
+
"""
|
|
3398
|
+
try:
|
|
3399
|
+
data = pty.read_nonblocking(size=4096, timeout=0.5)
|
|
3400
|
+
# Greedily read more available data to batch output (e.g. cat large_file)
|
|
3401
|
+
total = len(data) if data else 0
|
|
3402
|
+
while total < 32768:
|
|
3403
|
+
try:
|
|
3404
|
+
more = pty.read_nonblocking(size=4096, timeout=0.01)
|
|
3405
|
+
if more:
|
|
3406
|
+
data += more
|
|
3407
|
+
total += len(more)
|
|
3408
|
+
else:
|
|
3409
|
+
break
|
|
3410
|
+
except (pexpect.TIMEOUT, pexpect.EOF, Exception):
|
|
3411
|
+
break
|
|
3412
|
+
return data
|
|
3413
|
+
except pexpect.TIMEOUT:
|
|
3414
|
+
return ""
|
|
3415
|
+
except pexpect.EOF:
|
|
3416
|
+
return None
|
|
3417
|
+
except Exception:
|
|
3418
|
+
return None
|
|
3419
|
+
|
|
3420
|
+
|
|
3421
|
+
|
|
2129
3422
|
# ---------------------------------------------------------------------------
|
|
2130
3423
|
# Static file serving (built React app)
|
|
2131
3424
|
# ---------------------------------------------------------------------------
|