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.
Files changed (27) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/run.sh +61 -21
  4. package/dashboard/__init__.py +1 -1
  5. package/dashboard/server.py +59 -2
  6. package/docs/INSTALLATION.md +1 -1
  7. package/mcp/__init__.py +1 -1
  8. package/package.json +1 -1
  9. package/web-app/dist/assets/{Badge-Daan3gu4.js → Badge-CecAeNGh.js} +1 -1
  10. package/web-app/dist/assets/{Button-BfeQWtXn.js → Button-BAwZY3QB.js} +1 -1
  11. package/web-app/dist/assets/{Card-JqwSaE0I.js → Card-3YIYrz1X.js} +1 -1
  12. package/web-app/dist/assets/{HomePage-ZrDPLDGe.js → HomePage-DYS0zqqT.js} +1 -1
  13. package/web-app/dist/assets/{LoginPage-lJUDQIlI.js → LoginPage-D5Jj_Q44.js} +1 -1
  14. package/web-app/dist/assets/{NotFoundPage-kZTYx4v_.js → NotFoundPage-DLG6ORdp.js} +1 -1
  15. package/web-app/dist/assets/{ProjectPage-DayJk_FX.js → ProjectPage-D-ZyzZUT.js} +51 -46
  16. package/web-app/dist/assets/{ProjectsPage-4_PqKgaD.js → ProjectsPage-CMacaz1V.js} +1 -1
  17. package/web-app/dist/assets/{SettingsPage-DmjFCI0F.js → SettingsPage-B9XKC6ge.js} +1 -1
  18. package/web-app/dist/assets/{TemplatesPage-BOX60wWf.js → TemplatesPage-Bq8ASiy4.js} +1 -1
  19. package/web-app/dist/assets/{TerminalOutput-B9rfXUCC.js → TerminalOutput-rQ65EXIP.js} +1 -1
  20. package/web-app/dist/assets/{arrow-left-Rh7PJrlD.js → arrow-left-BcsRbWot.js} +1 -1
  21. package/web-app/dist/assets/{clock-CDe-IBc9.js → clock-DUeIWW98.js} +1 -1
  22. package/web-app/dist/assets/{external-link-BviPLjiY.js → external-link-lxSyZieU.js} +1 -1
  23. package/web-app/dist/assets/index-CQcaFLVo.css +1 -0
  24. package/web-app/dist/assets/{index--VmvfdEx.js → index-Cyfnu-vw.js} +22 -22
  25. package/web-app/dist/index.html +2 -2
  26. package/web-app/server.py +574 -31
  27. 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
- host_port = int(parts[-2])
645
- svc_ports.append(host_port)
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 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]
656
- break
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
- info["auto_fix_attempts"] = attempts + 1
1314
- timestamps.append(now)
1315
- info["auto_fix_timestamps"] = timestamps
1316
- backoff_seconds = 5 * (3 ** attempts)
1317
- error_context = "\n".join(info.get("output_lines", [])[-30:])
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
- async def _delayed_auto_fix():
1320
- await asyncio.sleep(backoff_seconds)
1321
- await self._auto_fix(session_id, error_context)
1322
-
1323
- try:
1324
- task = asyncio.ensure_future(_delayed_auto_fix())
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
- # Save original command before stop() removes the info dict
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
- await self.start(session_id, str(target), command=cmd)
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
- # Try to detect port from compose file
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"].values():
3664
- ports = svc.get("ports", [])
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
- compose_port = int(p_str.split(":")[0])
3669
- break
3670
- if compose_port != 3000:
3671
- break
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
  # ---------------------------------------------------------------------------