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.
- app/__init__.py +1 -0
- app/config.py +240 -0
- app/db.py +932 -0
- app/dedup_queue.py +77 -0
- app/engine_registry.py +56 -0
- app/engine_runtime.py +472 -0
- app/http_server.py +204 -0
- app/indexer.py +1532 -0
- app/main.py +147 -0
- app/models.py +39 -0
- app/queue_pipeline.py +65 -0
- app/ranking.py +144 -0
- app/registry.py +172 -0
- app/search_engine.py +572 -0
- app/watcher.py +124 -0
- app/workspace.py +286 -0
- deckard/__init__.py +3 -0
- deckard/__main__.py +4 -0
- deckard/main.py +345 -0
- deckard/version.py +1 -0
- mcp/__init__.py +1 -0
- mcp/__main__.py +19 -0
- mcp/cli.py +485 -0
- mcp/daemon.py +149 -0
- mcp/proxy.py +304 -0
- mcp/registry.py +218 -0
- mcp/server.py +519 -0
- mcp/session.py +234 -0
- mcp/telemetry.py +112 -0
- mcp/test_cli.py +89 -0
- mcp/test_daemon.py +124 -0
- mcp/test_server.py +197 -0
- mcp/tools/__init__.py +14 -0
- mcp/tools/_util.py +244 -0
- mcp/tools/deckard_guide.py +32 -0
- mcp/tools/doctor.py +208 -0
- mcp/tools/get_callers.py +60 -0
- mcp/tools/get_implementations.py +60 -0
- mcp/tools/index_file.py +75 -0
- mcp/tools/list_files.py +138 -0
- mcp/tools/read_file.py +48 -0
- mcp/tools/read_symbol.py +99 -0
- mcp/tools/registry.py +212 -0
- mcp/tools/repo_candidates.py +89 -0
- mcp/tools/rescan.py +46 -0
- mcp/tools/scan_once.py +54 -0
- mcp/tools/search.py +208 -0
- mcp/tools/search_api_endpoints.py +72 -0
- mcp/tools/search_symbols.py +63 -0
- mcp/tools/status.py +135 -0
- sari/__init__.py +1 -0
- sari/__main__.py +4 -0
- sari-0.0.1.dist-info/METADATA +521 -0
- sari-0.0.1.dist-info/RECORD +58 -0
- sari-0.0.1.dist-info/WHEEL +5 -0
- sari-0.0.1.dist-info/entry_points.txt +2 -0
- sari-0.0.1.dist-info/licenses/LICENSE +21 -0
- 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")
|