loki-mode 6.63.1 → 6.64.1
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/run.sh +61 -21
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +59 -2
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/web-app/dist/assets/{Badge-Daan3gu4.js → Badge-CecAeNGh.js} +1 -1
- package/web-app/dist/assets/{Button-BfeQWtXn.js → Button-BAwZY3QB.js} +1 -1
- package/web-app/dist/assets/{Card-JqwSaE0I.js → Card-3YIYrz1X.js} +1 -1
- package/web-app/dist/assets/{HomePage-ZrDPLDGe.js → HomePage-DYS0zqqT.js} +1 -1
- package/web-app/dist/assets/{LoginPage-lJUDQIlI.js → LoginPage-D5Jj_Q44.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-kZTYx4v_.js → NotFoundPage-DLG6ORdp.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-DayJk_FX.js → ProjectPage-D-ZyzZUT.js} +51 -46
- package/web-app/dist/assets/{ProjectsPage-4_PqKgaD.js → ProjectsPage-CMacaz1V.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-DmjFCI0F.js → SettingsPage-B9XKC6ge.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-BOX60wWf.js → TemplatesPage-Bq8ASiy4.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-B9rfXUCC.js → TerminalOutput-rQ65EXIP.js} +1 -1
- package/web-app/dist/assets/{arrow-left-Rh7PJrlD.js → arrow-left-BcsRbWot.js} +1 -1
- package/web-app/dist/assets/{clock-CDe-IBc9.js → clock-DUeIWW98.js} +1 -1
- package/web-app/dist/assets/{external-link-BviPLjiY.js → external-link-lxSyZieU.js} +1 -1
- package/web-app/dist/assets/index-CQcaFLVo.css +1 -0
- package/web-app/dist/assets/{index--VmvfdEx.js → index-Cyfnu-vw.js} +22 -22
- package/web-app/dist/index.html +2 -2
- package/web-app/server.py +574 -31
- package/web-app/dist/assets/index-DzYIpBt0.css +0 -1
package/web-app/server.py
CHANGED
|
@@ -641,19 +641,25 @@ class DevServerManager:
|
|
|
641
641
|
p_str = str(p)
|
|
642
642
|
if ":" in p_str:
|
|
643
643
|
parts = p_str.split(":")
|
|
644
|
-
|
|
645
|
-
|
|
644
|
+
try:
|
|
645
|
+
host_port = int(parts[-2].split("-")[0])
|
|
646
|
+
svc_ports.append(host_port)
|
|
647
|
+
except (ValueError, IndexError):
|
|
648
|
+
continue
|
|
646
649
|
services_info.append({
|
|
647
650
|
"name": svc_name,
|
|
648
651
|
"ports": svc_ports,
|
|
649
652
|
"image": svc.get("image"),
|
|
650
653
|
"has_build": "build" in svc,
|
|
651
654
|
})
|
|
652
|
-
# Use
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
655
|
+
# Use smart resolution to pick the user-facing service
|
|
656
|
+
_primary_name, port = self._resolve_primary_service(services_info)
|
|
657
|
+
if _primary_name is None:
|
|
658
|
+
# Fallback: first service with any port
|
|
659
|
+
for svc_entry in services_info:
|
|
660
|
+
if svc_entry["ports"]:
|
|
661
|
+
port = svc_entry["ports"][0]
|
|
662
|
+
break
|
|
657
663
|
except ImportError:
|
|
658
664
|
# yaml not available -- fall back to regex parsing
|
|
659
665
|
try:
|
|
@@ -912,6 +918,36 @@ class DevServerManager:
|
|
|
912
918
|
return port
|
|
913
919
|
return None
|
|
914
920
|
|
|
921
|
+
def _resolve_primary_service(self, services_info: list[dict]) -> tuple[Optional[str], int]:
|
|
922
|
+
"""Determine the primary user-facing service from Docker Compose services."""
|
|
923
|
+
frontend_names = {"frontend", "web", "client", "app", "ui", "next", "vite", "nginx"}
|
|
924
|
+
frontend_ports = {3000, 5173, 8080, 4200, 5000, 4000}
|
|
925
|
+
|
|
926
|
+
# Priority 1: Service name matches frontend patterns
|
|
927
|
+
for svc in services_info:
|
|
928
|
+
if svc["name"].lower() in frontend_names and svc.get("ports"):
|
|
929
|
+
return svc["name"], svc["ports"][0]
|
|
930
|
+
|
|
931
|
+
# Priority 2: Service has a frontend-typical port
|
|
932
|
+
for svc in services_info:
|
|
933
|
+
for p in svc.get("ports", []):
|
|
934
|
+
if p in frontend_ports:
|
|
935
|
+
return svc["name"], p
|
|
936
|
+
|
|
937
|
+
# Priority 3: Custom-built service (has build, no standard image like postgres/redis)
|
|
938
|
+
infra_images = {"postgres", "redis", "mongo", "mysql", "rabbitmq", "memcached", "elasticsearch"}
|
|
939
|
+
for svc in services_info:
|
|
940
|
+
img = (svc.get("image") or "").split(":")[0].split("/")[-1].lower()
|
|
941
|
+
if svc.get("has_build") and img not in infra_images and svc.get("ports"):
|
|
942
|
+
return svc["name"], svc["ports"][0]
|
|
943
|
+
|
|
944
|
+
# Fallback: first service with any port
|
|
945
|
+
for svc in services_info:
|
|
946
|
+
if svc.get("ports"):
|
|
947
|
+
return svc["name"], svc["ports"][0]
|
|
948
|
+
|
|
949
|
+
return None, 3000
|
|
950
|
+
|
|
915
951
|
def _install_pip_deps(self, project_path: Path, build_env: dict) -> None:
|
|
916
952
|
"""Install pip dependencies into a project venv (creates one if needed)."""
|
|
917
953
|
if not (project_path / "requirements.txt").exists():
|
|
@@ -1203,6 +1239,10 @@ class DevServerManager:
|
|
|
1203
1239
|
|
|
1204
1240
|
asyncio.create_task(self._monitor_output(session_id))
|
|
1205
1241
|
|
|
1242
|
+
# For Docker projects, also start the service health monitor
|
|
1243
|
+
if framework == "docker":
|
|
1244
|
+
asyncio.create_task(self._monitor_docker_services(session_id))
|
|
1245
|
+
|
|
1206
1246
|
# Wait for port detection (up to 30s)
|
|
1207
1247
|
for _ in range(60):
|
|
1208
1248
|
await asyncio.sleep(0.5)
|
|
@@ -1310,21 +1350,30 @@ class DevServerManager:
|
|
|
1310
1350
|
info["auto_fix_status"] = "circuit breaker open (3 failures in 5 min)"
|
|
1311
1351
|
logger.warning("Auto-fix circuit breaker open for session %s", session_id)
|
|
1312
1352
|
elif attempts < 3:
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1353
|
+
# BUG-V63-009: Prevent dual monitors from racing to auto-fix
|
|
1354
|
+
if not info.get("_auto_fixing"):
|
|
1355
|
+
info["_auto_fixing"] = True
|
|
1356
|
+
info["auto_fix_attempts"] = attempts + 1
|
|
1357
|
+
timestamps.append(now)
|
|
1358
|
+
info["auto_fix_timestamps"] = timestamps
|
|
1359
|
+
backoff_seconds = 5 * (3 ** attempts)
|
|
1360
|
+
error_context = "\n".join(info.get("output_lines", [])[-30:])
|
|
1361
|
+
|
|
1362
|
+
async def _delayed_auto_fix():
|
|
1363
|
+
try:
|
|
1364
|
+
await asyncio.sleep(backoff_seconds)
|
|
1365
|
+
await self._auto_fix(session_id, error_context)
|
|
1366
|
+
finally:
|
|
1367
|
+
_info = self.servers.get(session_id)
|
|
1368
|
+
if _info:
|
|
1369
|
+
_info["_auto_fixing"] = False
|
|
1318
1370
|
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
info["_auto_fix_task"] = task
|
|
1326
|
-
except Exception:
|
|
1327
|
-
logger.warning("Failed to schedule auto-fix for session %s", session_id, exc_info=True)
|
|
1371
|
+
try:
|
|
1372
|
+
task = asyncio.ensure_future(_delayed_auto_fix())
|
|
1373
|
+
info["_auto_fix_task"] = task
|
|
1374
|
+
except Exception:
|
|
1375
|
+
info["_auto_fixing"] = False
|
|
1376
|
+
logger.warning("Failed to schedule auto-fix for session %s", session_id, exc_info=True)
|
|
1328
1377
|
|
|
1329
1378
|
async def _monitor_backend_output(self, session_id: str) -> None:
|
|
1330
1379
|
"""Background task: read backend dev server stdout for multi-service setups."""
|
|
@@ -1387,8 +1436,27 @@ class DevServerManager:
|
|
|
1387
1436
|
f"DEV SERVER ERROR OUTPUT:\n{error_context}"
|
|
1388
1437
|
)
|
|
1389
1438
|
|
|
1390
|
-
#
|
|
1439
|
+
# Enrich with Docker context if applicable
|
|
1440
|
+
if info.get("framework") == "docker":
|
|
1441
|
+
try:
|
|
1442
|
+
docker_ctx = await _gather_docker_context(target)
|
|
1443
|
+
if docker_ctx.get("failing_services"):
|
|
1444
|
+
fix_message += "\n\nDOCKER SERVICE STATUS:\n" + json.dumps(docker_ctx["service_status"], indent=2)
|
|
1445
|
+
for svc_name, svc_logs in docker_ctx.get("service_logs", {}).items():
|
|
1446
|
+
if svc_name != "_combined":
|
|
1447
|
+
fix_message += f"\n\nFAILING SERVICE '{svc_name}' LOGS:\n{svc_logs}"
|
|
1448
|
+
diagnoses = _diagnose_errors("\n".join(docker_ctx.get("service_logs", {}).values()))
|
|
1449
|
+
if diagnoses:
|
|
1450
|
+
fix_message += "\n\nAUTO-DIAGNOSIS:\n" + "\n".join(
|
|
1451
|
+
f"- {d['diagnosis']}: {d['suggestion']}" for d in diagnoses)
|
|
1452
|
+
if docker_ctx.get("project_structure"):
|
|
1453
|
+
fix_message += "\n\nPROJECT FILES:\n" + docker_ctx["project_structure"]
|
|
1454
|
+
except Exception:
|
|
1455
|
+
logger.debug("Docker context gathering for auto-fix failed", exc_info=True)
|
|
1456
|
+
|
|
1457
|
+
# Save original command and multi_service flag before stop() removes the info dict
|
|
1391
1458
|
cmd = info.get("original_command")
|
|
1459
|
+
is_multi_service = info.get("multi_service", False)
|
|
1392
1460
|
|
|
1393
1461
|
try:
|
|
1394
1462
|
auto_fix_env = {**os.environ}
|
|
@@ -1410,7 +1478,12 @@ class DevServerManager:
|
|
|
1410
1478
|
# Restart the dev server
|
|
1411
1479
|
await self.stop(session_id)
|
|
1412
1480
|
await asyncio.sleep(1)
|
|
1413
|
-
|
|
1481
|
+
# BUG-V63-008: For multi-service sessions, omit command= to re-enter
|
|
1482
|
+
# the multi-service detection path in start()
|
|
1483
|
+
if is_multi_service:
|
|
1484
|
+
await self.start(session_id, str(target))
|
|
1485
|
+
else:
|
|
1486
|
+
await self.start(session_id, str(target), command=cmd)
|
|
1414
1487
|
# Transfer circuit breaker state to the new info dict
|
|
1415
1488
|
new_info = self.servers.get(session_id)
|
|
1416
1489
|
if new_info:
|
|
@@ -1458,6 +1531,11 @@ class DevServerManager:
|
|
|
1458
1531
|
if fix_task and not fix_task.done():
|
|
1459
1532
|
fix_task.cancel()
|
|
1460
1533
|
|
|
1534
|
+
# Cancel any tracked Docker service fix tasks (BUG-V64-003)
|
|
1535
|
+
for task in info.get("_fix_tasks", {}).values():
|
|
1536
|
+
if not task.done():
|
|
1537
|
+
task.cancel()
|
|
1538
|
+
|
|
1461
1539
|
# For Docker containers, run docker compose down
|
|
1462
1540
|
if info.get("framework") == "docker":
|
|
1463
1541
|
try:
|
|
@@ -1604,6 +1682,148 @@ class DevServerManager:
|
|
|
1604
1682
|
result["port"] = 1355
|
|
1605
1683
|
return result
|
|
1606
1684
|
|
|
1685
|
+
async def _monitor_docker_services(self, session_id: str) -> None:
|
|
1686
|
+
"""Background loop: poll Docker Compose services, auto-fix failures."""
|
|
1687
|
+
info = self.servers.get(session_id)
|
|
1688
|
+
if not info:
|
|
1689
|
+
return
|
|
1690
|
+
|
|
1691
|
+
project_dir = Path(info.get("project_dir", "."))
|
|
1692
|
+
info["docker_service_health"] = {}
|
|
1693
|
+
|
|
1694
|
+
while True:
|
|
1695
|
+
info = self.servers.get(session_id)
|
|
1696
|
+
if not info or not info.get("process") or info["process"].poll() is not None:
|
|
1697
|
+
break
|
|
1698
|
+
await asyncio.sleep(10)
|
|
1699
|
+
# Re-check after sleep in case server was stopped
|
|
1700
|
+
info = self.servers.get(session_id)
|
|
1701
|
+
if not info or not info.get("process") or info["process"].poll() is not None:
|
|
1702
|
+
break
|
|
1703
|
+
|
|
1704
|
+
try:
|
|
1705
|
+
docker_ctx = await _gather_docker_context(project_dir)
|
|
1706
|
+
except Exception:
|
|
1707
|
+
continue
|
|
1708
|
+
|
|
1709
|
+
# Update service health
|
|
1710
|
+
for svc in docker_ctx.get("service_status", []):
|
|
1711
|
+
name = svc["name"]
|
|
1712
|
+
prev = info["docker_service_health"].get(name, {})
|
|
1713
|
+
svc_health: dict = {
|
|
1714
|
+
"name": name,
|
|
1715
|
+
"status": svc["state"],
|
|
1716
|
+
"exit_code": svc.get("exit_code", 0),
|
|
1717
|
+
"restarts": prev.get("restarts", 0),
|
|
1718
|
+
"fix_attempts": prev.get("fix_attempts", 0),
|
|
1719
|
+
"fix_timestamps": prev.get("fix_timestamps", []),
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
# Detect new failure
|
|
1723
|
+
was_running = prev.get("status") in ("running", None)
|
|
1724
|
+
now_failed = svc["state"] in ("exited", "dead")
|
|
1725
|
+
|
|
1726
|
+
if was_running and now_failed:
|
|
1727
|
+
svc_health["restarts"] = prev.get("restarts", 0) + 1
|
|
1728
|
+
logger.warning("Docker service '%s' failed (exit %s)", name, svc.get("exit_code"))
|
|
1729
|
+
|
|
1730
|
+
# Circuit breaker: max 3 fixes per service per 10 min
|
|
1731
|
+
now = time.time()
|
|
1732
|
+
recent_fixes = [t for t in svc_health["fix_timestamps"] if now - t < 600]
|
|
1733
|
+
|
|
1734
|
+
if len(recent_fixes) < 3:
|
|
1735
|
+
# BUG-V63-009: Prevent dual monitors from racing to auto-fix
|
|
1736
|
+
if info.get("_auto_fixing"):
|
|
1737
|
+
svc_health["fix_status"] = "fix_in_progress"
|
|
1738
|
+
info["docker_service_health"][name] = svc_health
|
|
1739
|
+
continue
|
|
1740
|
+
|
|
1741
|
+
# BUG-V64-004: Skip if a fix task is already running for this service
|
|
1742
|
+
existing_task = info.get("_fix_tasks", {}).get(name)
|
|
1743
|
+
if existing_task and not existing_task.done():
|
|
1744
|
+
svc_health["fix_status"] = "fix_in_progress"
|
|
1745
|
+
info["docker_service_health"][name] = svc_health
|
|
1746
|
+
continue # Skip, previous fix still running
|
|
1747
|
+
|
|
1748
|
+
info["_auto_fixing"] = True
|
|
1749
|
+
svc_logs = docker_ctx.get("service_logs", {}).get(name, "")
|
|
1750
|
+
diagnoses = _diagnose_errors(svc_logs)
|
|
1751
|
+
diag_text = "\n".join(f"- {d['diagnosis']}: {d['suggestion']}" for d in diagnoses)
|
|
1752
|
+
|
|
1753
|
+
fix_prompt = f"The '{name}' Docker service crashed (exit code {svc.get('exit_code', 1)}).\n"
|
|
1754
|
+
if diag_text:
|
|
1755
|
+
fix_prompt += f"\nDiagnosis:\n{diag_text}\n"
|
|
1756
|
+
fix_prompt += f"\nService logs:\n{svc_logs[:2000]}\n"
|
|
1757
|
+
fix_prompt += "\nFix the issue and ensure the service starts correctly."
|
|
1758
|
+
|
|
1759
|
+
svc_health["fix_attempts"] += 1
|
|
1760
|
+
svc_health["fix_timestamps"] = recent_fixes + [now]
|
|
1761
|
+
svc_health["fix_status"] = "fixing"
|
|
1762
|
+
|
|
1763
|
+
# BUG-V64-003: Track fix tasks so they can be cancelled on stop
|
|
1764
|
+
if "_fix_tasks" not in info:
|
|
1765
|
+
info["_fix_tasks"] = {}
|
|
1766
|
+
task = asyncio.ensure_future(self._auto_fix_service(session_id, name, fix_prompt))
|
|
1767
|
+
info["_fix_tasks"][name] = task
|
|
1768
|
+
else:
|
|
1769
|
+
svc_health["fix_status"] = "circuit_breaker_open"
|
|
1770
|
+
|
|
1771
|
+
info["docker_service_health"][name] = svc_health
|
|
1772
|
+
|
|
1773
|
+
# Broadcast service health via WebSocket
|
|
1774
|
+
try:
|
|
1775
|
+
await _broadcast({
|
|
1776
|
+
"type": "service_health",
|
|
1777
|
+
"data": {
|
|
1778
|
+
"session_id": session_id,
|
|
1779
|
+
"services": list(info["docker_service_health"].values()),
|
|
1780
|
+
}
|
|
1781
|
+
})
|
|
1782
|
+
except Exception:
|
|
1783
|
+
pass
|
|
1784
|
+
|
|
1785
|
+
async def _auto_fix_service(self, session_id: str, service_name: str, fix_prompt: str) -> None:
|
|
1786
|
+
"""Run targeted fix for a specific Docker service."""
|
|
1787
|
+
info = self.servers.get(session_id)
|
|
1788
|
+
if not info:
|
|
1789
|
+
return
|
|
1790
|
+
project_dir = info.get("project_dir", ".")
|
|
1791
|
+
loki = _find_loki_cli()
|
|
1792
|
+
if not loki:
|
|
1793
|
+
info["_auto_fixing"] = False
|
|
1794
|
+
return
|
|
1795
|
+
|
|
1796
|
+
try:
|
|
1797
|
+
proc = await asyncio.to_thread(
|
|
1798
|
+
subprocess.run,
|
|
1799
|
+
[loki, "quick", fix_prompt],
|
|
1800
|
+
capture_output=True, text=True, cwd=project_dir, timeout=300,
|
|
1801
|
+
env={**os.environ, **_load_secrets()}
|
|
1802
|
+
)
|
|
1803
|
+
|
|
1804
|
+
# After fix, restart the specific service
|
|
1805
|
+
await asyncio.to_thread(
|
|
1806
|
+
subprocess.run,
|
|
1807
|
+
["docker", "compose", "restart", service_name],
|
|
1808
|
+
capture_output=True, cwd=project_dir, timeout=30
|
|
1809
|
+
)
|
|
1810
|
+
|
|
1811
|
+
# BUG-V64-005: Re-fetch from live info dict to avoid writing to detached dict
|
|
1812
|
+
info = self.servers.get(session_id)
|
|
1813
|
+
if info and "docker_service_health" in info and service_name in info["docker_service_health"]:
|
|
1814
|
+
info["docker_service_health"][service_name]["fix_status"] = "fixed" if proc.returncode == 0 else "fix_failed"
|
|
1815
|
+
except Exception as exc:
|
|
1816
|
+
logger.error("Auto-fix for service '%s' failed: %s", service_name, exc)
|
|
1817
|
+
# BUG-V64-005: Re-fetch from live info dict
|
|
1818
|
+
info = self.servers.get(session_id)
|
|
1819
|
+
if info and "docker_service_health" in info and service_name in info["docker_service_health"]:
|
|
1820
|
+
info["docker_service_health"][service_name]["fix_status"] = "fix_failed"
|
|
1821
|
+
finally:
|
|
1822
|
+
# BUG-V63-009: Clear the auto-fixing lock
|
|
1823
|
+
info = self.servers.get(session_id)
|
|
1824
|
+
if info:
|
|
1825
|
+
info["_auto_fixing"] = False
|
|
1826
|
+
|
|
1607
1827
|
async def stop_all(self) -> None:
|
|
1608
1828
|
"""Stop all dev servers (used on shutdown)."""
|
|
1609
1829
|
for sid in list(self.servers.keys()):
|
|
@@ -1613,6 +1833,160 @@ class DevServerManager:
|
|
|
1613
1833
|
dev_server_manager = DevServerManager()
|
|
1614
1834
|
|
|
1615
1835
|
|
|
1836
|
+
# ---------------------------------------------------------------------------
|
|
1837
|
+
# Docker context gathering and error diagnosis
|
|
1838
|
+
# ---------------------------------------------------------------------------
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
_docker_context_cache: dict[str, tuple[float, dict]] = {}
|
|
1842
|
+
|
|
1843
|
+
|
|
1844
|
+
async def _gather_docker_context(project_dir: Path) -> dict:
|
|
1845
|
+
"""Gather Docker Compose service status, logs, and project context."""
|
|
1846
|
+
cache_key = str(project_dir)
|
|
1847
|
+
now = time.time()
|
|
1848
|
+
cached = _docker_context_cache.get(cache_key)
|
|
1849
|
+
if cached and now - cached[0] < 30:
|
|
1850
|
+
return cached[1]
|
|
1851
|
+
|
|
1852
|
+
loop = asyncio.get_running_loop()
|
|
1853
|
+
result: dict = {"service_status": [], "failing_services": [], "service_logs": {},
|
|
1854
|
+
"project_structure": "", "env_keys": []}
|
|
1855
|
+
|
|
1856
|
+
# Get service status via docker compose ps
|
|
1857
|
+
try:
|
|
1858
|
+
ps_proc = await loop.run_in_executor(None, lambda: subprocess.run(
|
|
1859
|
+
["docker", "compose", "ps", "--format", "json"],
|
|
1860
|
+
capture_output=True, text=True, cwd=str(project_dir), timeout=10
|
|
1861
|
+
))
|
|
1862
|
+
if ps_proc.returncode == 0 and ps_proc.stdout.strip():
|
|
1863
|
+
raw = ps_proc.stdout.strip()
|
|
1864
|
+
# Handle both NDJSON (one object per line) and JSON array formats
|
|
1865
|
+
# Docker Compose v2.21+ returns a JSON array instead of NDJSON
|
|
1866
|
+
services_data: list = []
|
|
1867
|
+
try:
|
|
1868
|
+
parsed = json.loads(raw)
|
|
1869
|
+
if isinstance(parsed, list):
|
|
1870
|
+
services_data = parsed
|
|
1871
|
+
else:
|
|
1872
|
+
services_data = [parsed]
|
|
1873
|
+
except json.JSONDecodeError:
|
|
1874
|
+
# NDJSON format - one JSON object per line
|
|
1875
|
+
for line in raw.split("\n"):
|
|
1876
|
+
if line.strip():
|
|
1877
|
+
try:
|
|
1878
|
+
services_data.append(json.loads(line))
|
|
1879
|
+
except json.JSONDecodeError:
|
|
1880
|
+
pass
|
|
1881
|
+
for svc in services_data:
|
|
1882
|
+
if not isinstance(svc, dict):
|
|
1883
|
+
continue
|
|
1884
|
+
status_entry = {
|
|
1885
|
+
"name": svc.get("Service", svc.get("Name", "unknown")),
|
|
1886
|
+
"state": svc.get("State", "unknown"),
|
|
1887
|
+
"status": svc.get("Status", ""),
|
|
1888
|
+
"exit_code": svc.get("ExitCode", 0),
|
|
1889
|
+
}
|
|
1890
|
+
result["service_status"].append(status_entry)
|
|
1891
|
+
if status_entry["state"] in ("exited", "dead", "restarting"):
|
|
1892
|
+
result["failing_services"].append(status_entry["name"])
|
|
1893
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
1894
|
+
pass
|
|
1895
|
+
|
|
1896
|
+
# Get logs for failing services
|
|
1897
|
+
for svc_name in result["failing_services"]:
|
|
1898
|
+
try:
|
|
1899
|
+
log_proc = await loop.run_in_executor(None, lambda sn=svc_name: subprocess.run(
|
|
1900
|
+
["docker", "compose", "logs", "--tail", "50", sn],
|
|
1901
|
+
capture_output=True, text=True, cwd=str(project_dir), timeout=15
|
|
1902
|
+
))
|
|
1903
|
+
if log_proc.stdout:
|
|
1904
|
+
result["service_logs"][svc_name] = log_proc.stdout[-3000:] # Cap at 3KB
|
|
1905
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
1906
|
+
pass
|
|
1907
|
+
|
|
1908
|
+
# If no specific failures, get combined logs tail
|
|
1909
|
+
if not result["failing_services"]:
|
|
1910
|
+
try:
|
|
1911
|
+
log_proc = await loop.run_in_executor(None, lambda: subprocess.run(
|
|
1912
|
+
["docker", "compose", "logs", "--tail", "30"],
|
|
1913
|
+
capture_output=True, text=True, cwd=str(project_dir), timeout=15
|
|
1914
|
+
))
|
|
1915
|
+
if log_proc.stdout:
|
|
1916
|
+
result["service_logs"]["_combined"] = log_proc.stdout[-3000:]
|
|
1917
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
1918
|
+
pass
|
|
1919
|
+
|
|
1920
|
+
# Project structure
|
|
1921
|
+
try:
|
|
1922
|
+
ls_proc = await loop.run_in_executor(None, lambda: subprocess.run(
|
|
1923
|
+
["find", ".", "-maxdepth", "2", "-type", "f", "(",
|
|
1924
|
+
"-name", "*.py", "-o", "-name", "*.ts",
|
|
1925
|
+
"-o", "-name", "*.tsx", "-o", "-name", "*.js", "-o", "-name", "package.json",
|
|
1926
|
+
"-o", "-name", "requirements.txt", "-o", "-name", "Dockerfile",
|
|
1927
|
+
"-o", "-name", "docker-compose.yml", "-o", "-name", "*.env", ")"],
|
|
1928
|
+
capture_output=True, text=True, cwd=str(project_dir), timeout=5
|
|
1929
|
+
))
|
|
1930
|
+
result["project_structure"] = ls_proc.stdout[:2000] if ls_proc.stdout else ""
|
|
1931
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
1932
|
+
pass
|
|
1933
|
+
|
|
1934
|
+
# Env variable names (not values)
|
|
1935
|
+
env_file = project_dir / ".env"
|
|
1936
|
+
if env_file.exists():
|
|
1937
|
+
try:
|
|
1938
|
+
for line in env_file.read_text(errors="replace").split("\n"):
|
|
1939
|
+
line = line.strip()
|
|
1940
|
+
if line and not line.startswith("#") and "=" in line:
|
|
1941
|
+
result["env_keys"].append(line.split("=", 1)[0])
|
|
1942
|
+
except OSError:
|
|
1943
|
+
pass
|
|
1944
|
+
|
|
1945
|
+
_docker_context_cache[cache_key] = (time.time(), result)
|
|
1946
|
+
return result
|
|
1947
|
+
|
|
1948
|
+
|
|
1949
|
+
def _diagnose_errors(logs: str) -> list[dict]:
|
|
1950
|
+
"""Pattern-match common errors in Docker/service logs and return diagnoses."""
|
|
1951
|
+
diagnoses: list[dict] = []
|
|
1952
|
+
patterns = [
|
|
1953
|
+
(r"ModuleNotFoundError: No module named ['\"](\w+)['\"]",
|
|
1954
|
+
lambda m: {"pattern": "missing_python_dep", "diagnosis": f"Missing Python dependency: {m.group(1)}",
|
|
1955
|
+
"suggestion": f"Add '{m.group(1)}' to requirements.txt and rebuild"}),
|
|
1956
|
+
(r"Cannot find module ['\"]([^'\"]+)['\"]|ERR_MODULE_NOT_FOUND",
|
|
1957
|
+
lambda m: {"pattern": "missing_node_dep", "diagnosis": "Missing Node.js module",
|
|
1958
|
+
"suggestion": "Run 'npm install' in the service directory"}),
|
|
1959
|
+
(r"ECONNREFUSED.*:(\d+)|connection refused.*:(\d+)",
|
|
1960
|
+
lambda m: {"pattern": "connection_refused",
|
|
1961
|
+
"diagnosis": f"Connection refused on port {m.group(1) or m.group(2)}",
|
|
1962
|
+
"suggestion": "A dependent service may not be ready. Add retry logic or health check wait."}),
|
|
1963
|
+
(r"address already in use|EADDRINUSE",
|
|
1964
|
+
lambda m: {"pattern": "port_conflict", "diagnosis": "Port already in use",
|
|
1965
|
+
"suggestion": "Another process is using the port. Change the port or stop the conflicting process."}),
|
|
1966
|
+
(r"SyntaxError: (.+)",
|
|
1967
|
+
lambda m: {"pattern": "syntax_error", "diagnosis": f"Syntax error: {m.group(1)[:100]}",
|
|
1968
|
+
"suggestion": "Fix the syntax error in the indicated file and line."}),
|
|
1969
|
+
(r"FATAL:.*password authentication failed",
|
|
1970
|
+
lambda m: {"pattern": "db_auth", "diagnosis": "Database authentication failed",
|
|
1971
|
+
"suggestion": "Check DATABASE_URL credentials match the postgres service environment."}),
|
|
1972
|
+
(r"error.*returned a non-zero code: (\d+)",
|
|
1973
|
+
lambda m: {"pattern": "build_failure", "diagnosis": f"Docker build failed (exit code {m.group(1)})",
|
|
1974
|
+
"suggestion": "Check the Dockerfile for errors. Common: missing system dependencies."}),
|
|
1975
|
+
(r"npm ERR!|npm error",
|
|
1976
|
+
lambda m: {"pattern": "npm_error", "diagnosis": "npm encountered an error",
|
|
1977
|
+
"suggestion": "Check package.json for invalid dependencies or run 'npm install' manually."}),
|
|
1978
|
+
]
|
|
1979
|
+
seen: set[str] = set()
|
|
1980
|
+
for pattern, handler in patterns:
|
|
1981
|
+
for match in re.finditer(pattern, logs, re.IGNORECASE):
|
|
1982
|
+
diag = handler(match)
|
|
1983
|
+
key = diag["pattern"]
|
|
1984
|
+
if key not in seen:
|
|
1985
|
+
seen.add(key)
|
|
1986
|
+
diagnoses.append(diag)
|
|
1987
|
+
return diagnoses
|
|
1988
|
+
|
|
1989
|
+
|
|
1616
1990
|
# ---------------------------------------------------------------------------
|
|
1617
1991
|
# Helpers
|
|
1618
1992
|
# ---------------------------------------------------------------------------
|
|
@@ -3135,6 +3509,25 @@ async def chat_session(session_id: str, req: ChatRequest) -> JSONResponse:
|
|
|
3135
3509
|
"\n\nDEV SERVER ERROR (fix this):\n" + "\n".join(error_lines)
|
|
3136
3510
|
)
|
|
3137
3511
|
|
|
3512
|
+
# Phase 1.5: Inject Docker Compose context
|
|
3513
|
+
ds_info_docker = dev_server_manager.servers.get(session_id)
|
|
3514
|
+
if ds_info_docker and ds_info_docker.get("framework") == "docker":
|
|
3515
|
+
try:
|
|
3516
|
+
docker_ctx = await _gather_docker_context(target)
|
|
3517
|
+
if docker_ctx.get("failing_services"):
|
|
3518
|
+
context_parts.append("\n\nDOCKER SERVICE STATUS:\n" + json.dumps(docker_ctx["service_status"], indent=2))
|
|
3519
|
+
for svc_name, svc_logs in docker_ctx.get("service_logs", {}).items():
|
|
3520
|
+
if svc_name != "_combined":
|
|
3521
|
+
context_parts.append(f"\n\nFAILING SERVICE '{svc_name}' LOGS:\n{svc_logs}")
|
|
3522
|
+
diagnoses = _diagnose_errors("\n".join(docker_ctx.get("service_logs", {}).values()))
|
|
3523
|
+
if diagnoses:
|
|
3524
|
+
context_parts.append("\n\nAUTO-DIAGNOSIS:\n" + "\n".join(
|
|
3525
|
+
f"- {d['diagnosis']}: {d['suggestion']}" for d in diagnoses))
|
|
3526
|
+
if docker_ctx.get("project_structure"):
|
|
3527
|
+
context_parts.append("\n\nPROJECT FILES:\n" + docker_ctx["project_structure"])
|
|
3528
|
+
except Exception:
|
|
3529
|
+
logger.debug("Docker context gathering failed", exc_info=True)
|
|
3530
|
+
|
|
3138
3531
|
# Inject quality gate failures if any
|
|
3139
3532
|
gate_file = target / ".loki" / "quality" / "gate-failures.txt"
|
|
3140
3533
|
if gate_file.exists():
|
|
@@ -3653,22 +4046,32 @@ async def get_preview_info(session_id: str) -> JSONResponse:
|
|
|
3653
4046
|
info["type"] = "docker"
|
|
3654
4047
|
info["dev_command"] = f"docker compose -f {compose_file} up --build"
|
|
3655
4048
|
info["description"] = "Containerized app -- runs via Docker Compose"
|
|
3656
|
-
#
|
|
4049
|
+
# Use smart service resolution to find the primary port
|
|
3657
4050
|
compose_port = 3000
|
|
4051
|
+
compose_services: list[dict] = []
|
|
4052
|
+
primary_service_name: Optional[str] = None
|
|
3658
4053
|
try:
|
|
3659
4054
|
import yaml
|
|
3660
4055
|
with open(project_root / compose_file) as f:
|
|
3661
4056
|
compose_data = yaml.safe_load(f)
|
|
3662
4057
|
if compose_data and "services" in compose_data:
|
|
3663
|
-
for svc in compose_data["services"].
|
|
3664
|
-
|
|
3665
|
-
for p in ports:
|
|
4058
|
+
for svc_name, svc in compose_data["services"].items():
|
|
4059
|
+
svc_ports: list[int] = []
|
|
4060
|
+
for p in svc.get("ports", []):
|
|
3666
4061
|
p_str = str(p)
|
|
3667
4062
|
if ":" in p_str:
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
4063
|
+
parts = p_str.split(":")
|
|
4064
|
+
try:
|
|
4065
|
+
svc_ports.append(int(parts[-2].split("-")[0]))
|
|
4066
|
+
except (ValueError, IndexError):
|
|
4067
|
+
continue
|
|
4068
|
+
compose_services.append({
|
|
4069
|
+
"name": svc_name,
|
|
4070
|
+
"ports": svc_ports,
|
|
4071
|
+
"image": svc.get("image"),
|
|
4072
|
+
"has_build": "build" in svc,
|
|
4073
|
+
})
|
|
4074
|
+
primary_service_name, compose_port = dev_server_manager._resolve_primary_service(compose_services)
|
|
3672
4075
|
except ImportError:
|
|
3673
4076
|
try:
|
|
3674
4077
|
content = (project_root / compose_file).read_text()
|
|
@@ -3680,6 +4083,10 @@ async def get_preview_info(session_id: str) -> JSONResponse:
|
|
|
3680
4083
|
except Exception:
|
|
3681
4084
|
pass
|
|
3682
4085
|
info["port"] = compose_port
|
|
4086
|
+
if primary_service_name:
|
|
4087
|
+
info["primary_service"] = primary_service_name
|
|
4088
|
+
if compose_services:
|
|
4089
|
+
info["services"] = compose_services
|
|
3683
4090
|
elif is_expo:
|
|
3684
4091
|
info["type"] = "expo"
|
|
3685
4092
|
info["port"] = 8081
|
|
@@ -3897,6 +4304,142 @@ async def get_devserver_status(session_id: str) -> JSONResponse:
|
|
|
3897
4304
|
return JSONResponse(content=result)
|
|
3898
4305
|
|
|
3899
4306
|
|
|
4307
|
+
@app.get("/api/sessions/{session_id}/services")
|
|
4308
|
+
async def get_session_services(session_id: str) -> JSONResponse:
|
|
4309
|
+
"""Get Docker Compose service list with primary detection."""
|
|
4310
|
+
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
4311
|
+
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
4312
|
+
target = _find_session_dir(session_id)
|
|
4313
|
+
if target is None:
|
|
4314
|
+
return JSONResponse(status_code=404, content={"error": "Session not found"})
|
|
4315
|
+
|
|
4316
|
+
# Check if dev server info has cached services
|
|
4317
|
+
ds_info = dev_server_manager.servers.get(session_id)
|
|
4318
|
+
if ds_info and ds_info.get("docker_service_health"):
|
|
4319
|
+
return JSONResponse(content={
|
|
4320
|
+
"services": list(ds_info["docker_service_health"].values()),
|
|
4321
|
+
"framework": ds_info.get("framework"),
|
|
4322
|
+
})
|
|
4323
|
+
|
|
4324
|
+
# Parse compose file directly
|
|
4325
|
+
services_info: list[dict] = []
|
|
4326
|
+
for compose_file in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
|
|
4327
|
+
if (target / compose_file).exists():
|
|
4328
|
+
try:
|
|
4329
|
+
import yaml
|
|
4330
|
+
with open(target / compose_file) as f:
|
|
4331
|
+
compose_data = yaml.safe_load(f)
|
|
4332
|
+
if compose_data and "services" in compose_data:
|
|
4333
|
+
for svc_name, svc in compose_data["services"].items():
|
|
4334
|
+
svc_ports: list[int] = []
|
|
4335
|
+
for p in svc.get("ports", []):
|
|
4336
|
+
p_str = str(p)
|
|
4337
|
+
if ":" in p_str:
|
|
4338
|
+
parts = p_str.split(":")
|
|
4339
|
+
try:
|
|
4340
|
+
svc_ports.append(int(parts[-2].split("-")[0]))
|
|
4341
|
+
except (ValueError, IndexError):
|
|
4342
|
+
continue
|
|
4343
|
+
services_info.append({
|
|
4344
|
+
"name": svc_name,
|
|
4345
|
+
"ports": svc_ports,
|
|
4346
|
+
"image": svc.get("image"),
|
|
4347
|
+
"has_build": "build" in svc,
|
|
4348
|
+
})
|
|
4349
|
+
except (ImportError, Exception):
|
|
4350
|
+
pass
|
|
4351
|
+
break
|
|
4352
|
+
|
|
4353
|
+
primary_name, primary_port = dev_server_manager._resolve_primary_service(services_info)
|
|
4354
|
+
for svc in services_info:
|
|
4355
|
+
svc["is_primary"] = (svc["name"] == primary_name)
|
|
4356
|
+
|
|
4357
|
+
return JSONResponse(content={
|
|
4358
|
+
"services": services_info,
|
|
4359
|
+
"primary_service": primary_name,
|
|
4360
|
+
"primary_port": primary_port,
|
|
4361
|
+
})
|
|
4362
|
+
|
|
4363
|
+
|
|
4364
|
+
@app.get("/api/sessions/{session_id}/devserver/logs")
|
|
4365
|
+
async def get_devserver_logs(session_id: str, service: Optional[str] = None, tail: int = 50) -> JSONResponse:
|
|
4366
|
+
"""Get Docker service logs (optionally filtered to one service)."""
|
|
4367
|
+
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
4368
|
+
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
4369
|
+
target = _find_session_dir(session_id)
|
|
4370
|
+
if target is None:
|
|
4371
|
+
return JSONResponse(status_code=404, content={"error": "Session not found"})
|
|
4372
|
+
|
|
4373
|
+
ds_info = dev_server_manager.servers.get(session_id)
|
|
4374
|
+
if not ds_info:
|
|
4375
|
+
return JSONResponse(status_code=404, content={"error": "No dev server running"})
|
|
4376
|
+
|
|
4377
|
+
# If not a Docker project, return buffered output lines
|
|
4378
|
+
if ds_info.get("framework") != "docker":
|
|
4379
|
+
return JSONResponse(content={
|
|
4380
|
+
"logs": ds_info.get("output_lines", [])[-tail:],
|
|
4381
|
+
"service": None,
|
|
4382
|
+
})
|
|
4383
|
+
|
|
4384
|
+
# Docker project: use docker compose logs
|
|
4385
|
+
project_dir = ds_info.get("project_dir", str(target))
|
|
4386
|
+
tail = min(tail, 200) # Cap at 200 lines
|
|
4387
|
+
try:
|
|
4388
|
+
cmd = ["docker", "compose", "logs", "--tail", str(tail)]
|
|
4389
|
+
if service:
|
|
4390
|
+
# Validate service name to prevent injection
|
|
4391
|
+
if not re.match(r"^[a-zA-Z0-9._-]+$", service):
|
|
4392
|
+
return JSONResponse(status_code=400, content={"error": "Invalid service name"})
|
|
4393
|
+
cmd.append(service)
|
|
4394
|
+
loop = asyncio.get_running_loop()
|
|
4395
|
+
log_proc = await loop.run_in_executor(None, lambda: subprocess.run(
|
|
4396
|
+
cmd, capture_output=True, text=True, cwd=project_dir, timeout=15
|
|
4397
|
+
))
|
|
4398
|
+
logs_text = log_proc.stdout or ""
|
|
4399
|
+
# Run diagnosis on the logs
|
|
4400
|
+
diagnoses = _diagnose_errors(logs_text)
|
|
4401
|
+
return JSONResponse(content={
|
|
4402
|
+
"logs": logs_text[-5000:],
|
|
4403
|
+
"service": service,
|
|
4404
|
+
"diagnoses": diagnoses,
|
|
4405
|
+
})
|
|
4406
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
4407
|
+
return JSONResponse(status_code=500, content={"error": f"Failed to get logs: {exc}"})
|
|
4408
|
+
|
|
4409
|
+
|
|
4410
|
+
@app.post("/api/sessions/{session_id}/devserver/restart-service")
|
|
4411
|
+
async def restart_service(session_id: str, req: dict = Body(...)) -> JSONResponse:
|
|
4412
|
+
"""Restart a specific Docker Compose service."""
|
|
4413
|
+
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
4414
|
+
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
4415
|
+
ds_info = dev_server_manager.servers.get(session_id)
|
|
4416
|
+
if not ds_info:
|
|
4417
|
+
return JSONResponse(status_code=404, content={"error": "No dev server running"})
|
|
4418
|
+
if ds_info.get("framework") != "docker":
|
|
4419
|
+
return JSONResponse(status_code=400, content={"error": "Not a Docker project"})
|
|
4420
|
+
|
|
4421
|
+
service_name = req.get("service")
|
|
4422
|
+
if not service_name or not re.match(r"^[a-zA-Z0-9._-]+$", service_name):
|
|
4423
|
+
return JSONResponse(status_code=400, content={"error": "Invalid or missing service name"})
|
|
4424
|
+
|
|
4425
|
+
project_dir = ds_info.get("project_dir", ".")
|
|
4426
|
+
try:
|
|
4427
|
+
loop = asyncio.get_running_loop()
|
|
4428
|
+
result = await loop.run_in_executor(None, lambda: subprocess.run(
|
|
4429
|
+
["docker", "compose", "restart", service_name],
|
|
4430
|
+
capture_output=True, text=True, cwd=project_dir, timeout=30
|
|
4431
|
+
))
|
|
4432
|
+
if result.returncode == 0:
|
|
4433
|
+
return JSONResponse(content={"status": "restarted", "service": service_name})
|
|
4434
|
+
else:
|
|
4435
|
+
return JSONResponse(status_code=500, content={
|
|
4436
|
+
"error": f"Restart failed: {result.stderr or result.stdout}",
|
|
4437
|
+
"service": service_name,
|
|
4438
|
+
})
|
|
4439
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
4440
|
+
return JSONResponse(status_code=500, content={"error": f"Restart failed: {exc}"})
|
|
4441
|
+
|
|
4442
|
+
|
|
3900
4443
|
# ---------------------------------------------------------------------------
|
|
3901
4444
|
# HTTP Proxy for dev server preview
|
|
3902
4445
|
# ---------------------------------------------------------------------------
|