loki-mode 6.62.0 → 6.63.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/web-app/server.py CHANGED
@@ -627,25 +627,32 @@ class DevServerManager:
627
627
  subprocess.run(["docker", "--version"], capture_output=True, timeout=5)
628
628
  except (FileNotFoundError, subprocess.TimeoutExpired):
629
629
  break # Docker not installed -- fall through to other detection
630
- # Parse compose file to detect exposed port
630
+ # Parse compose file to detect exposed port and enumerate services
631
631
  port = 3000 # default
632
+ services_info: list = []
632
633
  try:
633
634
  import yaml
634
635
  with open(root / compose_file) as f:
635
636
  compose = yaml.safe_load(f)
636
637
  if compose and "services" in compose:
637
- for svc in compose["services"].values():
638
- ports = svc.get("ports", [])
639
- for p in ports:
638
+ for svc_name, svc in compose["services"].items():
639
+ svc_ports: list = []
640
+ for p in svc.get("ports", []):
640
641
  p_str = str(p)
641
642
  if ":" in p_str:
642
- # Handle IP:host:container (e.g. "127.0.0.1:8080:80")
643
- # and host:container (e.g. "8080:80")
644
643
  parts = p_str.split(":")
645
- host_port = parts[-2] # second-to-last is always host port
646
- port = int(host_port)
647
- break
648
- if port != 3000:
644
+ host_port = int(parts[-2])
645
+ svc_ports.append(host_port)
646
+ services_info.append({
647
+ "name": svc_name,
648
+ "ports": svc_ports,
649
+ "image": svc.get("image"),
650
+ "has_build": "build" in svc,
651
+ })
652
+ # Use the first exposed host port as the primary port
653
+ for svc_entry in services_info:
654
+ if svc_entry["ports"]:
655
+ port = svc_entry["ports"][0]
649
656
  break
650
657
  except ImportError:
651
658
  # yaml not available -- fall back to regex parsing
@@ -654,11 +661,149 @@ class DevServerManager:
654
661
  port_match = re.search(r'"?(\d+):(\d+)"?', content)
655
662
  if port_match:
656
663
  port = int(port_match.group(1))
664
+ # Extract service names via regex
665
+ for m in re.finditer(r'^ (\w[\w-]*):\s*$', content, re.MULTILINE):
666
+ services_info.append({"name": m.group(1), "ports": []})
657
667
  except Exception:
658
668
  pass
659
669
  except Exception:
660
670
  pass
661
- return {"command": f"docker compose -f {compose_file} up --build", "expected_port": port, "framework": "docker"}
671
+ result_dict: dict = {
672
+ "command": f"docker compose -f {compose_file} up --build",
673
+ "expected_port": port,
674
+ "framework": "docker",
675
+ }
676
+ if services_info:
677
+ result_dict["services"] = services_info
678
+ return result_dict
679
+
680
+ # -- Full-stack project detection (frontend + backend in subdirectories) --
681
+ frontend_dir_names = ["frontend", "client", "web", "app", "ui", "web-app", "webapp"]
682
+ backend_dir_names = ["backend", "server", "api", "service"]
683
+
684
+ frontend_dir: Optional[Path] = None
685
+ backend_dir: Optional[Path] = None
686
+
687
+ for d in frontend_dir_names:
688
+ candidate = root / d
689
+ if candidate.is_dir():
690
+ # Verify it is actually a frontend (has package.json or index.html)
691
+ if (candidate / "package.json").exists() or (candidate / "index.html").exists():
692
+ frontend_dir = candidate
693
+ break
694
+
695
+ for d in backend_dir_names:
696
+ candidate = root / d
697
+ if candidate.is_dir():
698
+ has_py = any(candidate.glob("*.py"))
699
+ has_pkg = (candidate / "package.json").exists()
700
+ has_go = (candidate / "go.mod").exists()
701
+ has_requirements = (candidate / "requirements.txt").exists()
702
+ has_cargo = (candidate / "Cargo.toml").exists()
703
+ if has_py or has_pkg or has_go or has_requirements or has_cargo:
704
+ backend_dir = candidate
705
+ break
706
+
707
+ if frontend_dir and backend_dir:
708
+ # Detect frontend framework and command
709
+ fe_cmd = "npm run dev"
710
+ fe_port = 3000
711
+ fe_framework = "node"
712
+ fe_pkg = frontend_dir / "package.json"
713
+ if fe_pkg.exists():
714
+ try:
715
+ pkg = json.loads(fe_pkg.read_text(errors="replace"))
716
+ fe_scripts = pkg.get("scripts", {})
717
+ fe_deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
718
+ if "next" in fe_deps:
719
+ fe_framework = "next"
720
+ fe_port = 3000
721
+ fe_cmd = "npm run dev"
722
+ elif "vite" in fe_deps:
723
+ fe_framework = "vite"
724
+ fe_port = 5173
725
+ fe_cmd = "npm run dev"
726
+ elif "dev" in fe_scripts:
727
+ fe_cmd = "npm run dev"
728
+ elif "start" in fe_scripts:
729
+ fe_cmd = "npm start"
730
+ except Exception:
731
+ pass
732
+ elif (frontend_dir / "index.html").exists():
733
+ fe_framework = "static"
734
+ fe_cmd = "python3 -m http.server 3000"
735
+ fe_port = 3000
736
+
737
+ # Detect backend framework and command
738
+ be_cmd: Optional[str] = None
739
+ be_port = 8000
740
+ be_framework = "unknown"
741
+
742
+ if (backend_dir / "manage.py").exists():
743
+ be_cmd = "python manage.py runserver"
744
+ be_port = 8000
745
+ be_framework = "django"
746
+ else:
747
+ for py_entry in ("app.py", "main.py", "server.py"):
748
+ py_file = backend_dir / py_entry
749
+ if py_file.exists():
750
+ try:
751
+ src = py_file.read_text(errors="replace")[:4096]
752
+ if "fastapi" in src.lower() or "FastAPI" in src:
753
+ module = py_entry[:-3]
754
+ be_cmd = f"uvicorn {module}:app --reload --port 8000"
755
+ be_port = 8000
756
+ be_framework = "fastapi"
757
+ break
758
+ if "flask" in src.lower() or "Flask" in src:
759
+ be_cmd = "flask run --port 5000"
760
+ be_port = 5000
761
+ be_framework = "flask"
762
+ break
763
+ except OSError:
764
+ pass
765
+ if be_cmd is None and (backend_dir / "package.json").exists():
766
+ try:
767
+ be_pkg = json.loads((backend_dir / "package.json").read_text(errors="replace"))
768
+ be_scripts = be_pkg.get("scripts", {})
769
+ be_deps = {**be_pkg.get("dependencies", {}), **be_pkg.get("devDependencies", {})}
770
+ if "express" in be_deps:
771
+ be_framework = "express"
772
+ else:
773
+ be_framework = "node"
774
+ be_port = 3001
775
+ if "dev" in be_scripts:
776
+ be_cmd = "npm run dev"
777
+ elif "start" in be_scripts:
778
+ be_cmd = "npm start"
779
+ except Exception:
780
+ pass
781
+ if be_cmd is None and (backend_dir / "go.mod").exists():
782
+ be_cmd = "go run ."
783
+ be_port = 8080
784
+ be_framework = "go"
785
+ if be_cmd is None and (backend_dir / "requirements.txt").exists():
786
+ # Generic Python backend with requirements.txt
787
+ for py_entry in ("app.py", "main.py", "server.py", "run.py"):
788
+ if (backend_dir / py_entry).exists():
789
+ be_cmd = f"python {py_entry}"
790
+ be_port = 8000
791
+ be_framework = "python"
792
+ break
793
+
794
+ if be_cmd:
795
+ return {
796
+ "framework": "full-stack",
797
+ "command": f"cd {backend_dir.name} && {be_cmd}",
798
+ "frontend_command": f"cd {frontend_dir.name} && {fe_cmd}",
799
+ "expected_port": fe_port,
800
+ "backend_port": be_port,
801
+ "multi_service": True,
802
+ "frontend_dir": str(frontend_dir),
803
+ "backend_dir": str(backend_dir),
804
+ "frontend_framework": fe_framework,
805
+ "backend_framework": be_framework,
806
+ }
662
807
 
663
808
  pkg_json = root / "package.json"
664
809
  if pkg_json.exists():
@@ -767,6 +912,36 @@ class DevServerManager:
767
912
  return port
768
913
  return None
769
914
 
915
+ def _install_pip_deps(self, project_path: Path, build_env: dict) -> None:
916
+ """Install pip dependencies into a project venv (creates one if needed)."""
917
+ if not (project_path / "requirements.txt").exists():
918
+ return
919
+ venv_dir = None
920
+ for venv_name in ("venv", ".venv", "env"):
921
+ candidate = project_path / venv_name
922
+ if candidate.is_dir() and (candidate / "bin" / "pip").exists():
923
+ venv_dir = candidate
924
+ break
925
+ if venv_dir is None:
926
+ try:
927
+ subprocess.run(
928
+ [sys.executable, "-m", "venv", str(project_path / "venv")],
929
+ capture_output=True, timeout=60,
930
+ )
931
+ venv_dir = project_path / "venv"
932
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
933
+ logger.warning("venv creation failed: %s", exc)
934
+ pip_executable = str(venv_dir / "bin" / "pip") if venv_dir else "pip"
935
+ try:
936
+ subprocess.run(
937
+ [pip_executable, "install", "-r", "requirements.txt"],
938
+ cwd=str(project_path),
939
+ capture_output=True,
940
+ timeout=120,
941
+ )
942
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
943
+ logger.warning("pip install failed: %s", exc)
944
+
770
945
  async def start(self, session_id: str, project_dir: str, command: Optional[str] = None) -> dict:
771
946
  """Start dev server. Auto-detect command if not provided."""
772
947
  if session_id in self.servers:
@@ -792,15 +967,177 @@ class DevServerManager:
792
967
  cmd_str = command or (detected["command"] if detected else "")
793
968
  expected_port = detected["expected_port"] if detected else 3000
794
969
  framework = detected["framework"] if detected else "unknown"
970
+ is_multi_service = detected.get("multi_service", False) if detected else False
971
+
972
+ build_env = {**os.environ}
973
+ build_env.update(_load_secrets())
974
+
975
+ # -- Multi-service full-stack startup --
976
+ if is_multi_service and not command:
977
+ be_dir = detected.get("backend_dir", actual_dir)
978
+ fe_dir = detected.get("frontend_dir", actual_dir)
979
+ be_cmd_str = detected.get("command", "")
980
+ fe_cmd_str = detected.get("frontend_command", "")
981
+ be_port = detected.get("backend_port", 8000)
982
+ fe_port = detected.get("expected_port", 3000)
983
+
984
+ # Install deps in both directories
985
+ for svc_dir_str in (be_dir, fe_dir):
986
+ svc_path = Path(svc_dir_str)
987
+ if (svc_path / "package.json").exists() and not (svc_path / "node_modules").exists():
988
+ try:
989
+ subprocess.run(["npm", "install"], cwd=svc_dir_str, capture_output=True, timeout=120, env=build_env)
990
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
991
+ logger.warning("npm install failed in %s: %s", svc_dir_str, exc)
992
+ if (svc_path / "requirements.txt").exists():
993
+ self._install_pip_deps(svc_path, build_env)
994
+
995
+ popen_kwargs = (
996
+ {"start_new_session": True} if sys.platform != "win32"
997
+ else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
998
+ )
999
+
1000
+ # Start backend process (uses shell=True because command contains 'cd ...')
1001
+ try:
1002
+ be_proc = subprocess.Popen(
1003
+ be_cmd_str, shell=True,
1004
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
1005
+ text=True, cwd=actual_dir, env=build_env, **popen_kwargs,
1006
+ )
1007
+ except Exception as e:
1008
+ return {"status": "error", "message": f"Failed to start backend: {e}"}
1009
+ _track_child_pid(be_proc.pid)
1010
+
1011
+ # Start frontend process
1012
+ try:
1013
+ fe_proc = subprocess.Popen(
1014
+ fe_cmd_str, shell=True,
1015
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
1016
+ text=True, cwd=actual_dir, env=build_env, **popen_kwargs,
1017
+ )
1018
+ except Exception as e:
1019
+ # Kill backend if frontend fails to start
1020
+ try:
1021
+ be_proc.terminate()
1022
+ except (ProcessLookupError, PermissionError, OSError):
1023
+ pass
1024
+ _untrack_child_pid(be_proc.pid)
1025
+ return {"status": "error", "message": f"Failed to start frontend: {e}"}
1026
+ _track_child_pid(fe_proc.pid)
1027
+
1028
+ server_info: dict = {
1029
+ "process": fe_proc, # Primary process is frontend (user-facing)
1030
+ "backend_process": be_proc,
1031
+ "port": None,
1032
+ "expected_port": fe_port,
1033
+ "backend_port": be_port,
1034
+ "command": fe_cmd_str,
1035
+ "original_command": cmd_str,
1036
+ "framework": framework,
1037
+ "status": "starting",
1038
+ "pid": fe_proc.pid,
1039
+ "backend_pid": be_proc.pid,
1040
+ "project_dir": project_dir,
1041
+ "output_lines": [],
1042
+ "backend_output_lines": [],
1043
+ "multi_service": True,
1044
+ "frontend_framework": detected.get("frontend_framework", "unknown"),
1045
+ "backend_framework": detected.get("backend_framework", "unknown"),
1046
+ "frontend_dir": fe_dir,
1047
+ "backend_dir": be_dir,
1048
+ "use_portless": False,
1049
+ "portless_app_name": None,
1050
+ }
1051
+ self.servers[session_id] = server_info
1052
+
1053
+ asyncio.create_task(self._monitor_output(session_id))
1054
+ asyncio.create_task(self._monitor_backend_output(session_id))
1055
+
1056
+ # Wait for either frontend or backend port (up to 30s)
1057
+ for _ in range(60):
1058
+ await asyncio.sleep(0.5)
1059
+ info = self.servers.get(session_id)
1060
+ if not info:
1061
+ return {"status": "error", "message": "Server entry disappeared"}
1062
+ if info["status"] == "error":
1063
+ return {
1064
+ "status": "error",
1065
+ "message": "Dev server crashed",
1066
+ "output": info["output_lines"][-10:] if info["output_lines"] else [],
1067
+ }
1068
+ if info["port"] is not None:
1069
+ health_ok = await self._health_check(info["port"])
1070
+ if health_ok:
1071
+ info["status"] = "running"
1072
+ services = [
1073
+ {
1074
+ "name": "frontend",
1075
+ "framework": info.get("frontend_framework", "unknown"),
1076
+ "port": fe_port,
1077
+ "status": "running",
1078
+ },
1079
+ {
1080
+ "name": "backend",
1081
+ "framework": info.get("backend_framework", "unknown"),
1082
+ "port": be_port,
1083
+ "status": "running" if be_proc.poll() is None else "error",
1084
+ },
1085
+ ]
1086
+ return {
1087
+ "status": "running",
1088
+ "port": info["port"],
1089
+ "command": fe_cmd_str,
1090
+ "pid": fe_proc.pid,
1091
+ "url": f"/proxy/{session_id}/",
1092
+ "multi_service": True,
1093
+ "framework": "full-stack",
1094
+ "services": services,
1095
+ }
1096
+
1097
+ # Timeout -- report whatever state we have
1098
+ if fe_proc.poll() is not None and be_proc.poll() is not None:
1099
+ server_info["status"] = "error"
1100
+ return {
1101
+ "status": "error",
1102
+ "message": "Both frontend and backend exited before port was detected",
1103
+ "output": server_info["output_lines"][-10:],
1104
+ }
1105
+
1106
+ # Fallback to expected port
1107
+ health_ok = await self._health_check(fe_port)
1108
+ if health_ok:
1109
+ server_info["port"] = fe_port
1110
+ server_info["status"] = "running"
1111
+ return {
1112
+ "status": "running",
1113
+ "port": fe_port,
1114
+ "command": fe_cmd_str,
1115
+ "pid": fe_proc.pid,
1116
+ "url": f"/proxy/{session_id}/",
1117
+ "multi_service": True,
1118
+ "framework": "full-stack",
1119
+ }
1120
+
1121
+ server_info["status"] = "starting"
1122
+ server_info["port"] = fe_port
1123
+ return {
1124
+ "status": "starting",
1125
+ "message": "Server started but port not yet confirmed",
1126
+ "port": fe_port,
1127
+ "command": fe_cmd_str,
1128
+ "pid": fe_proc.pid,
1129
+ "url": f"/proxy/{session_id}/",
1130
+ "multi_service": True,
1131
+ "framework": "full-stack",
1132
+ }
1133
+
1134
+ # -- Single-service startup (original path) --
795
1135
 
796
1136
  # Auto-install dependencies before starting the dev server
797
1137
  actual_path = Path(actual_dir)
798
1138
  needs_npm = (actual_path / "package.json").exists() and not (actual_path / "node_modules").exists()
799
1139
  needs_pip = (actual_path / "requirements.txt").exists() and not (actual_path / "venv").exists()
800
1140
 
801
- build_env = {**os.environ}
802
- build_env.update(_load_secrets())
803
-
804
1141
  if needs_npm:
805
1142
  try:
806
1143
  subprocess.run(
@@ -814,34 +1151,7 @@ class DevServerManager:
814
1151
  logger.warning("npm install failed: %s", exc)
815
1152
 
816
1153
  if needs_pip:
817
- # Use project venv if available, otherwise create one to avoid
818
- # installing into the server's own Python environment.
819
- venv_dir = None
820
- for venv_name in ("venv", ".venv", "env"):
821
- candidate = actual_path / venv_name
822
- if candidate.is_dir() and (candidate / "bin" / "pip").exists():
823
- venv_dir = candidate
824
- break
825
- if venv_dir is None:
826
- # Create a virtual environment for the project
827
- try:
828
- subprocess.run(
829
- [sys.executable, "-m", "venv", str(actual_path / "venv")],
830
- capture_output=True, timeout=60,
831
- )
832
- venv_dir = actual_path / "venv"
833
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
834
- logger.warning("venv creation failed: %s", exc)
835
- pip_executable = str(venv_dir / "bin" / "pip") if venv_dir else "pip"
836
- try:
837
- subprocess.run(
838
- [pip_executable, "install", "-r", "requirements.txt"],
839
- cwd=actual_dir,
840
- capture_output=True,
841
- timeout=120,
842
- )
843
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
844
- logger.warning("pip install failed: %s", exc)
1154
+ self._install_pip_deps(actual_path, build_env)
845
1155
 
846
1156
  # Check if portless is available and proxy is running
847
1157
  use_portless = False
@@ -875,7 +1185,7 @@ class DevServerManager:
875
1185
  _track_child_pid(proc.pid)
876
1186
 
877
1187
  effective_cmd = " ".join(cmd_parts)
878
- server_info: dict = {
1188
+ server_info = {
879
1189
  "process": proc,
880
1190
  "port": None,
881
1191
  "expected_port": expected_port,
@@ -1016,6 +1326,39 @@ class DevServerManager:
1016
1326
  except Exception:
1017
1327
  logger.warning("Failed to schedule auto-fix for session %s", session_id, exc_info=True)
1018
1328
 
1329
+ async def _monitor_backend_output(self, session_id: str) -> None:
1330
+ """Background task: read backend dev server stdout for multi-service setups."""
1331
+ info = self.servers.get(session_id)
1332
+ if not info or not info.get("multi_service"):
1333
+ return
1334
+ be_proc = info.get("backend_process")
1335
+ if not be_proc or not be_proc.stdout:
1336
+ return
1337
+ loop = asyncio.get_running_loop()
1338
+ try:
1339
+ while be_proc.poll() is None:
1340
+ line = await loop.run_in_executor(None, be_proc.stdout.readline)
1341
+ if not line:
1342
+ break
1343
+ text = line.rstrip("\n")
1344
+ be_lines = info.get("backend_output_lines", [])
1345
+ be_lines.append(text)
1346
+ if len(be_lines) > 200:
1347
+ be_lines = be_lines[-200:]
1348
+ info["backend_output_lines"] = be_lines
1349
+ # Also detect backend port from output
1350
+ detected_port = self._parse_port(text)
1351
+ if detected_port:
1352
+ info["backend_port"] = detected_port
1353
+ except Exception:
1354
+ logger.error("Backend monitor failed for session %s", session_id, exc_info=True)
1355
+ finally:
1356
+ if be_proc.poll() is not None and info.get("status") in ("starting", "running"):
1357
+ # Only mark error if frontend is also dead
1358
+ fe_proc = info.get("process")
1359
+ if fe_proc and fe_proc.poll() is not None:
1360
+ info["status"] = "error"
1361
+
1019
1362
  async def _auto_fix(self, session_id: str, error_context: str) -> None:
1020
1363
  """Auto-fix a crashed dev server by invoking loki quick with the error."""
1021
1364
  info = self.servers.get(session_id)
@@ -1169,6 +1512,34 @@ class DevServerManager:
1169
1512
  pass
1170
1513
 
1171
1514
  _untrack_child_pid(proc.pid)
1515
+
1516
+ # For multi-service setups, also kill the backend process
1517
+ be_proc = info.get("backend_process")
1518
+ if be_proc:
1519
+ if be_proc.poll() is None:
1520
+ if sys.platform != "win32":
1521
+ try:
1522
+ pgid = os.getpgid(be_proc.pid)
1523
+ os.killpg(pgid, signal.SIGTERM)
1524
+ except (ProcessLookupError, PermissionError, OSError):
1525
+ try:
1526
+ be_proc.terminate()
1527
+ except (ProcessLookupError, PermissionError, OSError):
1528
+ pass
1529
+ else:
1530
+ try:
1531
+ be_proc.terminate()
1532
+ except (ProcessLookupError, PermissionError, OSError):
1533
+ pass
1534
+ try:
1535
+ be_proc.wait(timeout=5)
1536
+ except subprocess.TimeoutExpired:
1537
+ try:
1538
+ be_proc.kill()
1539
+ except (ProcessLookupError, PermissionError, OSError):
1540
+ pass
1541
+ _untrack_child_pid(be_proc.pid)
1542
+
1172
1543
  return {"stopped": True, "message": "Dev server stopped"}
1173
1544
 
1174
1545
  async def status(self, session_id: str) -> dict:
@@ -1203,6 +1574,29 @@ class DevServerManager:
1203
1574
  "auto_fix_status": info.get("auto_fix_status"),
1204
1575
  "auto_fix_attempts": info.get("auto_fix_attempts", 0),
1205
1576
  }
1577
+
1578
+ # Multi-service status reporting
1579
+ if info.get("multi_service"):
1580
+ be_proc = info.get("backend_process")
1581
+ be_alive = be_proc.poll() is None if be_proc else False
1582
+ result["multi_service"] = True
1583
+ result["framework"] = "full-stack"
1584
+ result["services"] = [
1585
+ {
1586
+ "name": "frontend",
1587
+ "framework": info.get("frontend_framework", "unknown"),
1588
+ "port": info.get("expected_port"),
1589
+ "status": "running" if alive else "error",
1590
+ },
1591
+ {
1592
+ "name": "backend",
1593
+ "framework": info.get("backend_framework", "unknown"),
1594
+ "port": info.get("backend_port"),
1595
+ "status": "running" if be_alive else "error",
1596
+ },
1597
+ ]
1598
+ result["backend_output"] = info.get("backend_output_lines", [])[-20:]
1599
+
1206
1600
  if info.get("use_portless") and info.get("portless_app_name"):
1207
1601
  app_name = info["portless_app_name"]
1208
1602
  result["portless_url"] = f"http://{app_name}.localhost:1355/"