sari 0.0.1__py3-none-any.whl

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 (58) hide show
  1. app/__init__.py +1 -0
  2. app/config.py +240 -0
  3. app/db.py +932 -0
  4. app/dedup_queue.py +77 -0
  5. app/engine_registry.py +56 -0
  6. app/engine_runtime.py +472 -0
  7. app/http_server.py +204 -0
  8. app/indexer.py +1532 -0
  9. app/main.py +147 -0
  10. app/models.py +39 -0
  11. app/queue_pipeline.py +65 -0
  12. app/ranking.py +144 -0
  13. app/registry.py +172 -0
  14. app/search_engine.py +572 -0
  15. app/watcher.py +124 -0
  16. app/workspace.py +286 -0
  17. deckard/__init__.py +3 -0
  18. deckard/__main__.py +4 -0
  19. deckard/main.py +345 -0
  20. deckard/version.py +1 -0
  21. mcp/__init__.py +1 -0
  22. mcp/__main__.py +19 -0
  23. mcp/cli.py +485 -0
  24. mcp/daemon.py +149 -0
  25. mcp/proxy.py +304 -0
  26. mcp/registry.py +218 -0
  27. mcp/server.py +519 -0
  28. mcp/session.py +234 -0
  29. mcp/telemetry.py +112 -0
  30. mcp/test_cli.py +89 -0
  31. mcp/test_daemon.py +124 -0
  32. mcp/test_server.py +197 -0
  33. mcp/tools/__init__.py +14 -0
  34. mcp/tools/_util.py +244 -0
  35. mcp/tools/deckard_guide.py +32 -0
  36. mcp/tools/doctor.py +208 -0
  37. mcp/tools/get_callers.py +60 -0
  38. mcp/tools/get_implementations.py +60 -0
  39. mcp/tools/index_file.py +75 -0
  40. mcp/tools/list_files.py +138 -0
  41. mcp/tools/read_file.py +48 -0
  42. mcp/tools/read_symbol.py +99 -0
  43. mcp/tools/registry.py +212 -0
  44. mcp/tools/repo_candidates.py +89 -0
  45. mcp/tools/rescan.py +46 -0
  46. mcp/tools/scan_once.py +54 -0
  47. mcp/tools/search.py +208 -0
  48. mcp/tools/search_api_endpoints.py +72 -0
  49. mcp/tools/search_symbols.py +63 -0
  50. mcp/tools/status.py +135 -0
  51. sari/__init__.py +1 -0
  52. sari/__main__.py +4 -0
  53. sari-0.0.1.dist-info/METADATA +521 -0
  54. sari-0.0.1.dist-info/RECORD +58 -0
  55. sari-0.0.1.dist-info/WHEEL +5 -0
  56. sari-0.0.1.dist-info/entry_points.txt +2 -0
  57. sari-0.0.1.dist-info/licenses/LICENSE +21 -0
  58. sari-0.0.1.dist-info/top_level.txt +4 -0
app/main.py ADDED
@@ -0,0 +1,147 @@
1
+ import json
2
+ import os
3
+ import signal
4
+ import threading
5
+ import time
6
+ import ipaddress
7
+ from pathlib import Path
8
+ from datetime import datetime
9
+
10
+ # Support both `python3 app/main.py` (script mode) and package mode.
11
+ try:
12
+ from .config import Config, resolve_config_path # type: ignore
13
+ from . import config as config_mod # type: ignore
14
+ from .db import LocalSearchDB # type: ignore
15
+ from .http_server import serve_forever # type: ignore
16
+ from .indexer import Indexer # type: ignore
17
+ from .workspace import WorkspaceManager # type: ignore
18
+ except ImportError: # script mode
19
+ from config import Config, resolve_config_path # type: ignore
20
+ import config as config_mod # type: ignore
21
+ from db import LocalSearchDB # type: ignore
22
+ from http_server import serve_forever # type: ignore
23
+ from indexer import Indexer # type: ignore
24
+ from workspace import WorkspaceManager # type: ignore
25
+
26
+
27
+ def _repo_root() -> str:
28
+ # Fallback to current working directory if not running from a nested structure
29
+ return str(Path.cwd())
30
+
31
+
32
+ def main() -> int:
33
+ # v2.3.2: Auto-detect workspace root for HTTP fallback
34
+ workspace_root = WorkspaceManager.resolve_workspace_root()
35
+
36
+ # Set env var so Config can pick it up
37
+ os.environ["LOCAL_SEARCH_WORKSPACE_ROOT"] = workspace_root
38
+
39
+ cfg_path = resolve_config_path(workspace_root)
40
+
41
+ # Graceful config loading (Global Install Support)
42
+ if os.path.exists(cfg_path):
43
+ cfg = Config.load(cfg_path)
44
+ else:
45
+ # Use safe defaults if config.json is missing.
46
+ print(f"[sari] Config not found in workspace ({cfg_path}), using defaults.")
47
+ defaults = config_mod.Config.get_defaults(workspace_root)
48
+ cfg = Config(**defaults)
49
+
50
+
51
+ # Security hardening: loopback-only by default.
52
+ # Allow opt-in override only when explicitly requested.
53
+ allow_non_loopback = os.environ.get("LOCAL_SEARCH_ALLOW_NON_LOOPBACK") == "1"
54
+ host = (cfg.http_api_host or "127.0.0.1").strip()
55
+ try:
56
+ is_loopback = host.lower() == "localhost" or ipaddress.ip_address(host).is_loopback
57
+ except ValueError:
58
+ # Non-IP hostnames are only allowed if they resolve to localhost explicitly.
59
+ is_loopback = host.lower() == "localhost"
60
+
61
+ if (not is_loopback) and (not allow_non_loopback):
62
+ raise SystemExit(
63
+ f"sari refused to start: server_host must be loopback only (127.0.0.1/localhost/::1). got={host}. "
64
+ "Set LOCAL_SEARCH_ALLOW_NON_LOOPBACK=1 to override (NOT recommended)."
65
+ )
66
+
67
+ # v2.4.1: Workspace-local DB path enforcement (multi-workspace support)
68
+ # DB path is now determined by Config.load
69
+ db_path = cfg.db_path
70
+ Path(db_path).parent.mkdir(parents=True, exist_ok=True)
71
+
72
+ print(f"[sari] DB path: {db_path}")
73
+
74
+ db = LocalSearchDB(db_path)
75
+ try:
76
+ from app.engine_registry import get_default_engine
77
+ db.set_engine(get_default_engine(db, cfg, cfg.workspace_roots))
78
+ except Exception as e:
79
+ print(f"[sari] engine init failed: {e}")
80
+ from app.indexer import resolve_indexer_settings
81
+ mode, enabled, startup_enabled, lock_handle = resolve_indexer_settings(str(db_path))
82
+ indexer = Indexer(cfg, db, indexer_mode=mode, indexing_enabled=enabled, startup_index_enabled=startup_enabled, lock_handle=lock_handle)
83
+
84
+ # Start HTTP immediately so health checks don't block on initial indexing.
85
+ # v2.3.3: serve_forever returns (httpd, actual_port) for fallback tracking
86
+ version = os.environ.get("DECKARD_VERSION", "dev")
87
+ httpd, actual_port = serve_forever(host, cfg.http_api_port, db, indexer, version=version, workspace_root=workspace_root)
88
+
89
+ # Write server.json with actual binding info (single source of truth for port tracking)
90
+ data_dir = Path(workspace_root) / ".codex" / "tools" / "sari" / "data"
91
+ data_dir.mkdir(parents=True, exist_ok=True)
92
+ server_json = data_dir / "server.json"
93
+ server_info = {
94
+ "host": host,
95
+ "port": actual_port, # v2.3.3: use actual bound port, not config port
96
+ "config_port": cfg.http_api_port, # original requested port for reference
97
+ "pid": os.getpid(),
98
+ "started_at": datetime.now().isoformat(),
99
+ }
100
+ server_json.write_text(json.dumps(server_info, indent=2), encoding="utf-8")
101
+
102
+ if actual_port != cfg.http_api_port:
103
+ print(f"[sari] server.json updated with fallback port {actual_port}")
104
+
105
+ try:
106
+ port_file = Path(db_path + ".http_api.port")
107
+ port_file.write_text(str(actual_port) + "\n", encoding="utf-8")
108
+ except Exception:
109
+ pass
110
+
111
+ stop_evt = threading.Event()
112
+
113
+ def _shutdown(*_):
114
+ if stop_evt.is_set():
115
+ return
116
+ stop_evt.set()
117
+ try:
118
+ indexer.stop()
119
+ except Exception:
120
+ pass
121
+ try:
122
+ httpd.shutdown()
123
+ except Exception:
124
+ pass
125
+ try:
126
+ db.close()
127
+ except Exception:
128
+ pass
129
+
130
+ signal.signal(signal.SIGINT, _shutdown)
131
+ signal.signal(signal.SIGTERM, _shutdown)
132
+
133
+ # Index in background.
134
+ idx_thread = threading.Thread(target=indexer.run_forever, daemon=True)
135
+ idx_thread.start()
136
+
137
+ try:
138
+ while not stop_evt.is_set():
139
+ time.sleep(0.2)
140
+ finally:
141
+ _shutdown()
142
+
143
+ return 0
144
+
145
+
146
+ if __name__ == "__main__":
147
+ raise SystemExit(main())
app/models.py ADDED
@@ -0,0 +1,39 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional, List
3
+
4
+ @dataclass
5
+ class SearchHit:
6
+ """Enhanced search result with metadata."""
7
+ repo: str
8
+ path: str
9
+ score: float
10
+ snippet: str
11
+ # v2.3.1: Added metadata
12
+ mtime: int = 0
13
+ size: int = 0
14
+ match_count: int = 0
15
+ file_type: str = ""
16
+ hit_reason: str = "" # v2.4.3: Added hit reason
17
+ context_symbol: str = "" # v2.6.0: Enclosing symbol context
18
+ docstring: str = "" # v2.9.0: Docstring/Javadoc
19
+ metadata: str = "{}" # v2.9.0: Raw metadata JSON
20
+
21
+
22
+ @dataclass
23
+ class SearchOptions:
24
+ """Search configuration options (v2.5.1)."""
25
+ query: str = ""
26
+ repo: Optional[str] = None
27
+ limit: int = 20
28
+ offset: int = 0
29
+ snippet_lines: int = 5
30
+ # Filtering
31
+ file_types: List[str] = field(default_factory=list) # e.g., ["py", "ts"]
32
+ path_pattern: Optional[str] = None # e.g., "src/**/*.ts"
33
+ exclude_patterns: List[str] = field(default_factory=list) # e.g., ["node_modules", "build"]
34
+ recency_boost: bool = False
35
+ use_regex: bool = False
36
+ case_sensitive: bool = False
37
+ root_ids: List[str] = field(default_factory=list)
38
+ # Pagination & Performance (v2.5.1)
39
+ total_mode: str = "exact" # "exact" | "approx"
app/queue_pipeline.py ADDED
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from typing import Iterable, List, Optional, Tuple
6
+
7
+
8
+ class FsEventKind(str, Enum):
9
+ CREATED = "CREATED"
10
+ MODIFIED = "MODIFIED"
11
+ DELETED = "DELETED"
12
+ MOVED = "MOVED"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class FsEvent:
17
+ kind: FsEventKind
18
+ path: str
19
+ dest_path: Optional[str] = None
20
+ ts: float = 0.0
21
+
22
+
23
+ class TaskAction(str, Enum):
24
+ INDEX = "INDEX"
25
+ DELETE = "DELETE"
26
+
27
+
28
+ @dataclass
29
+ class CoalesceTask:
30
+ action: TaskAction
31
+ path: str
32
+ attempts: int = 0
33
+ enqueue_ts: float = 0.0
34
+ last_seen: float = 0.0
35
+
36
+
37
+ @dataclass
38
+ class DbTask:
39
+ kind: str
40
+ rows: Optional[List[tuple]] = None
41
+ path: Optional[str] = None
42
+ paths: Optional[List[str]] = None
43
+ ts: Optional[int] = None
44
+ repo_meta: Optional[dict] = None
45
+ engine_docs: Optional[List[dict]] = None
46
+ engine_deletes: Optional[List[str]] = None
47
+
48
+
49
+ def coalesce_action(existing: Optional[TaskAction], incoming: TaskAction) -> TaskAction:
50
+ if incoming == TaskAction.DELETE:
51
+ return TaskAction.DELETE
52
+ if existing == TaskAction.DELETE:
53
+ return TaskAction.DELETE
54
+ return TaskAction.INDEX
55
+
56
+
57
+ def split_moved_event(event: FsEvent) -> List[Tuple[TaskAction, str]]:
58
+ if event.kind != FsEventKind.MOVED:
59
+ return []
60
+ actions: List[Tuple[TaskAction, str]] = []
61
+ if event.path:
62
+ actions.append((TaskAction.DELETE, event.path))
63
+ if event.dest_path:
64
+ actions.append((TaskAction.INDEX, event.dest_path))
65
+ return actions
app/ranking.py ADDED
@@ -0,0 +1,144 @@
1
+ import re
2
+ import time
3
+ import fnmatch
4
+ from pathlib import Path
5
+ from typing import List, Optional, Any
6
+
7
+ def glob_to_like(pattern: str) -> str:
8
+ """Convert glob-style pattern to SQL LIKE pattern for 1st-pass filtering."""
9
+ if not pattern:
10
+ return "%"
11
+
12
+ # v2.5.4: Better glob-to-like conversion
13
+ res = pattern.replace("**", "%").replace("*", "%").replace("?", "_")
14
+
15
+ if not ("%" in res or "_" in res):
16
+ res = f"%{res}%" # Contains if no wildcards
17
+
18
+ # Ensure it starts/ends correctly for directory patterns
19
+ if pattern.endswith("/**"):
20
+ res = res.rstrip("%") + "%"
21
+
22
+ while "%%" in res:
23
+ res = res.replace("%%", "%")
24
+ return res
25
+
26
+ def get_file_extension(path: str) -> str:
27
+ ext = Path(path).suffix
28
+ return ext[1:].lower() if ext else ""
29
+
30
+ def calculate_recency_score(mtime: int, base_score: float) -> float:
31
+ now = time.time()
32
+ age_days = (now - mtime) / 86400
33
+ if age_days < 1:
34
+ boost = 1.5
35
+ elif age_days < 7:
36
+ boost = 1.3
37
+ elif age_days < 30:
38
+ boost = 1.1
39
+ else:
40
+ boost = 1.0
41
+
42
+ # v2.5.4: Ensure boost works even if base_score is 0 (bias added)
43
+ return (base_score + 0.1) * boost
44
+
45
+ def extract_terms(q: str) -> List[str]:
46
+ # v2.5.4: Use regex to extract quoted phrases or space-separated words
47
+ raw = re.findall(r'"([^"]*)"|\'([^\']*)\'|(\S+)', q or "")
48
+ out: List[str] = []
49
+ for group in raw:
50
+ # group is a tuple of (double_quoted, single_quoted, bare_word)
51
+ t = group[0] or group[1] or group[2]
52
+ t = t.strip()
53
+ if not t or t in {"AND", "OR", "NOT"}:
54
+ continue
55
+ if ":" in t and len(t.split(":", 1)[0]) <= 10:
56
+ t = t.split(":", 1)[1]
57
+ t = t.strip()
58
+ if t:
59
+ out.append(t)
60
+ return out
61
+
62
+ def count_matches(content: str, query: str, use_regex: bool, case_sensitive: bool) -> int:
63
+ if not query: return 0
64
+ if use_regex:
65
+ flags = 0 if case_sensitive else re.IGNORECASE
66
+ try:
67
+ return len(re.findall(query, content, flags))
68
+ except re.error:
69
+ return 0
70
+ else:
71
+ if case_sensitive:
72
+ return content.count(query)
73
+ # v2.7.0: Use regex for case-insensitive count to better handle unicode
74
+ try:
75
+ return len(re.findall(re.escape(query), content, re.IGNORECASE))
76
+ except Exception:
77
+ # Fallback to simple count if regex fails for any reason
78
+ return content.lower().count(query.lower())
79
+
80
+ def snippet_around(content: str, terms: List[str], max_lines: int,
81
+ highlight: bool = True) -> str:
82
+ if max_lines <= 0:
83
+ return ""
84
+ lines = content.splitlines()
85
+ if not lines:
86
+ return ""
87
+
88
+ lower_lines = [l.lower() for l in lines]
89
+ lower_terms = [t.lower() for t in terms if t.strip()]
90
+
91
+ if not lower_terms:
92
+ return "\n".join(f"L{i+1}: {ln}" for i, ln in enumerate(lines[:max_lines]))
93
+
94
+ # Score per line
95
+ # +1 per match, +5 if definition (def/class) AND match
96
+ line_scores = [0] * len(lines)
97
+ def_pattern = re.compile(r"\b(class|def|function|struct|interface|type)\s+", re.IGNORECASE)
98
+
99
+ has_any_match = False
100
+ for i, line_lower in enumerate(lower_lines):
101
+ score = 0
102
+ for t in lower_terms:
103
+ if t in line_lower:
104
+ score += 1
105
+
106
+ if score > 0:
107
+ has_any_match = True
108
+ if def_pattern.search(line_lower):
109
+ score += 5
110
+
111
+ line_scores[i] = score
112
+
113
+ if not has_any_match:
114
+ return "\n".join(f"L{i+1}: {ln}" for i, ln in enumerate(lines[:max_lines]))
115
+
116
+ # Find best window (Sliding Window)
117
+ window_size = min(len(lines), max_lines)
118
+ current_score = sum(line_scores[:window_size])
119
+ best_window_score = current_score
120
+ best_start = 0
121
+
122
+ for i in range(1, len(lines) - window_size + 1):
123
+ current_score = current_score - line_scores[i-1] + line_scores[i + window_size - 1]
124
+ if current_score > best_window_score:
125
+ best_window_score = current_score
126
+ best_start = i
127
+
128
+ # Extract window
129
+ start_idx = best_start
130
+ end_idx = start_idx + window_size
131
+
132
+ out_lines = []
133
+ highlight_patterns = [re.compile(re.escape(t), re.IGNORECASE) for t in terms if t.strip()]
134
+
135
+ for i in range(start_idx, end_idx):
136
+ line = lines[i]
137
+ if highlight:
138
+ for pat in highlight_patterns:
139
+ # Use backreference to preserve case
140
+ line = pat.sub(r">>>\g<0><<<", line)
141
+
142
+ out_lines.append(f"L{i+1}: {line}")
143
+
144
+ return "\n".join(out_lines)
app/registry.py ADDED
@@ -0,0 +1,172 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import time
5
+ import socket
6
+ from pathlib import Path
7
+ from typing import Dict, Optional, Any
8
+
9
+ # Cross-platform file locking
10
+ IS_WINDOWS = os.name == 'nt'
11
+ if not IS_WINDOWS:
12
+ import fcntl
13
+
14
+ # Local Standard Path
15
+ if os.environ.get("DECKARD_REGISTRY_FILE"):
16
+ REGISTRY_FILE = Path(os.environ["DECKARD_REGISTRY_FILE"]).resolve()
17
+ REGISTRY_DIR = REGISTRY_FILE.parent
18
+ else:
19
+ REGISTRY_DIR = Path.home() / ".local" / "share" / "sari"
20
+ REGISTRY_FILE = REGISTRY_DIR / "server.json"
21
+
22
+ class ServerRegistry:
23
+ """
24
+ Manages the 'server.json' registry for Sari Daemons.
25
+ Maps Workspace Root Paths -> {Port, PID, Status}.
26
+ Thread/Process safe via fcntl locking.
27
+ """
28
+
29
+ def __init__(self):
30
+ REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
31
+ if not REGISTRY_FILE.exists():
32
+ self._write_empty()
33
+
34
+ def _write_empty(self):
35
+ with open(REGISTRY_FILE, "w") as f:
36
+ json.dump({"version": "1.0", "instances": {}}, f)
37
+
38
+ def _load(self) -> Dict[str, Any]:
39
+ """Load registry with lock."""
40
+ try:
41
+ with open(REGISTRY_FILE, "r+") as f:
42
+ if not IS_WINDOWS:
43
+ fcntl.flock(f, fcntl.LOCK_SH)
44
+ try:
45
+ return json.load(f)
46
+ except json.JSONDecodeError:
47
+ return {"version": "1.0", "instances": {}}
48
+ finally:
49
+ if not IS_WINDOWS:
50
+ fcntl.flock(f, fcntl.LOCK_UN)
51
+ except FileNotFoundError:
52
+ return {"version": "1.0", "instances": {}}
53
+
54
+ def _save(self, data: Dict[str, Any]):
55
+ """Save registry with lock."""
56
+ with open(REGISTRY_FILE, "w") as f:
57
+ if not IS_WINDOWS:
58
+ fcntl.flock(f, fcntl.LOCK_EX)
59
+ try:
60
+ json.dump(data, f, indent=2)
61
+ finally:
62
+ if not IS_WINDOWS:
63
+ fcntl.flock(f, fcntl.LOCK_UN)
64
+
65
+ def register(self, workspace_root: str, port: int, pid: int) -> None:
66
+ """Register a running daemon."""
67
+ # Normalize path
68
+ workspace_root = str(Path(workspace_root).resolve())
69
+
70
+ # Read-Modify-Write loop needs EX lock on read too if strict,
71
+ # but simple file lock wrapper is okay for low contention.
72
+ # Ideally open "r+" with LOCK_EX, read, seek 0, write, truncate.
73
+
74
+ with open(REGISTRY_FILE, "r+") as f:
75
+ if not IS_WINDOWS:
76
+ fcntl.flock(f, fcntl.LOCK_EX)
77
+ try:
78
+ try:
79
+ data = json.load(f)
80
+ except:
81
+ data = {"version": "1.0", "instances": {}}
82
+
83
+ instances = data.get("instances", {})
84
+ instances[workspace_root] = {
85
+ "port": port,
86
+ "pid": pid,
87
+ "start_ts": time.time(),
88
+ "status": "active"
89
+ }
90
+ data["instances"] = instances
91
+
92
+ f.seek(0)
93
+ json.dump(data, f, indent=2)
94
+ f.truncate()
95
+ finally:
96
+ if not IS_WINDOWS:
97
+ fcntl.flock(f, fcntl.LOCK_UN)
98
+
99
+ def unregister(self, workspace_root: str) -> None:
100
+ """Remove a daemon (on shutdown)."""
101
+ workspace_root = str(Path(workspace_root).resolve())
102
+
103
+ with open(REGISTRY_FILE, "r+") as f:
104
+ if not IS_WINDOWS:
105
+ fcntl.flock(f, fcntl.LOCK_EX)
106
+ try:
107
+ try:
108
+ data = json.load(f)
109
+ except:
110
+ return
111
+
112
+ instances = data.get("instances", {})
113
+ if workspace_root in instances:
114
+ del instances[workspace_root]
115
+ data["instances"] = instances
116
+
117
+ f.seek(0)
118
+ json.dump(data, f, indent=2)
119
+ f.truncate()
120
+ finally:
121
+ if not IS_WINDOWS:
122
+ fcntl.flock(f, fcntl.LOCK_UN)
123
+
124
+ def get_instance(self, workspace_root: str) -> Optional[Dict[str, Any]]:
125
+ """Get info for a workspace daemon. Checks liveness."""
126
+ workspace_root = str(Path(workspace_root).resolve())
127
+ data = self._load()
128
+ inst = data.get("instances", {}).get(workspace_root)
129
+
130
+ if not inst:
131
+ return None
132
+
133
+ # Check if process is actually alive
134
+ pid = inst.get("pid")
135
+ if not self._is_process_alive(pid):
136
+ # Lazy cleanup? Or just return None.
137
+ # Let's clean up lazily if we have the lock, but here we just have read lock (via load).
138
+ # Just return None, cleanup happens on next write or dedicated gc.
139
+ return None
140
+
141
+ return inst
142
+
143
+ def _is_process_alive(self, pid: int) -> bool:
144
+ if not pid: return False
145
+ try:
146
+ os.kill(pid, 0) # Signal 0 checks existence
147
+ return True
148
+ except OSError:
149
+ return False
150
+
151
+ def find_free_port(self, start_port: int = 47777, max_port: int = 65535) -> int:
152
+ """Find a port not in use by other instances AND OS."""
153
+ # 1. Get used ports from registry
154
+ data = self._load()
155
+ used_ports = {
156
+ info["port"] for info in data.get("instances", {}).values()
157
+ if self._is_process_alive(info.get("pid"))
158
+ }
159
+
160
+ for port in range(start_port, max_port + 1):
161
+ if port in used_ports:
162
+ continue
163
+
164
+ # 2. Check OS binding
165
+ try:
166
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
167
+ s.bind(("127.0.0.1", port))
168
+ return port
169
+ except OSError:
170
+ continue
171
+
172
+ raise RuntimeError("No free ports available")