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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/hooks/migration-hooks.sh +10 -1
- package/autonomy/issue-providers.sh +7 -2
- package/autonomy/run.sh +444 -83
- package/autonomy/sandbox.sh +5 -2
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +11 -2
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/memory/engine.py +1 -0
- package/package.json +1 -1
- package/state/manager.py +127 -32
- package/web-app/server.py +437 -43
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"].
|
|
638
|
-
|
|
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]
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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/"
|