synap-git 0.2.0__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.
- synap_git/__init__.py +5 -0
- synap_git/api/__init__.py +5 -0
- synap_git/api/app.py +111 -0
- synap_git/api/static/index.html +615 -0
- synap_git/cli/__init__.py +3 -0
- synap_git/cli/__main__.py +4 -0
- synap_git/cli/main.py +1174 -0
- synap_git/config.py +169 -0
- synap_git/diagnostics/__init__.py +0 -0
- synap_git/diagnostics/logger.py +82 -0
- synap_git/diagnostics/tracing.py +71 -0
- synap_git/embeddings/__init__.py +0 -0
- synap_git/git/__init__.py +11 -0
- synap_git/git/state.py +139 -0
- synap_git/indexer/__init__.py +0 -0
- synap_git/indexer/daemon.py +143 -0
- synap_git/indexer/engine.py +327 -0
- synap_git/indexer/scanner.py +463 -0
- synap_git/indexer/wiki.py +78 -0
- synap_git/mcp/__init__.py +5 -0
- synap_git/mcp/server.py +232 -0
- synap_git/parser/__init__.py +0 -0
- synap_git/parser/registry.py +209 -0
- synap_git/provider/anthropic.py +86 -0
- synap_git/provider/base.py +44 -0
- synap_git/provider/factory.py +101 -0
- synap_git/provider/gemini.py +98 -0
- synap_git/provider/mock.py +66 -0
- synap_git/provider/ollama.py +98 -0
- synap_git/provider/openai.py +90 -0
- synap_git/py.typed +1 -0
- synap_git/retrieval/__init__.py +0 -0
- synap_git/retrieval/engine.py +258 -0
- synap_git/retrieval/memory.py +123 -0
- synap_git/storage/__init__.py +3 -0
- synap_git/storage/sqlite.py +575 -0
- synap_git/utils/__init__.py +0 -0
- synap_git/utils/serialization.py +68 -0
- synap_git-0.2.0.dist-info/METADATA +251 -0
- synap_git-0.2.0.dist-info/RECORD +43 -0
- synap_git-0.2.0.dist-info/WHEEL +4 -0
- synap_git-0.2.0.dist-info/entry_points.txt +2 -0
- synap_git-0.2.0.dist-info/licenses/LICENSE.md +201 -0
synap_git/config.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
import keyring
|
|
9
|
+
from pydantic import Field, field_validator, model_validator
|
|
10
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RuntimeProfile(StrEnum):
|
|
14
|
+
DEV = "dev"
|
|
15
|
+
TEST = "test"
|
|
16
|
+
PROD = "prod"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RuntimeMode(StrEnum):
|
|
20
|
+
IDLE = "idle"
|
|
21
|
+
ACTIVE = "active"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LoggingMode(StrEnum):
|
|
25
|
+
HUMAN = "human"
|
|
26
|
+
JSON = "json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_config_file_path() -> Path:
|
|
30
|
+
"""Get global Synap config file path (XDG-compliant)."""
|
|
31
|
+
if env_path := os.environ.get("SYNAP_CONFIG"):
|
|
32
|
+
return Path(env_path).expanduser()
|
|
33
|
+
return Path.home() / ".config" / "synap" / "config.toml"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SynapSettings(BaseSettings):
|
|
37
|
+
"""Production-grade configuration with secure secret management."""
|
|
38
|
+
|
|
39
|
+
model_config = SettingsConfigDict(
|
|
40
|
+
env_prefix="SYNAP_",
|
|
41
|
+
env_file=str(_get_config_file_path()),
|
|
42
|
+
env_file_encoding="utf-8",
|
|
43
|
+
case_sensitive=False,
|
|
44
|
+
extra="ignore",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
profile: RuntimeProfile = RuntimeProfile.DEV
|
|
48
|
+
mode: RuntimeMode = RuntimeMode.ACTIVE
|
|
49
|
+
|
|
50
|
+
repository_path: Path = Field(default=Path(), description="Repository root.")
|
|
51
|
+
state_path: Path = Field(default=Path(".synap"), description="Local runtime state.")
|
|
52
|
+
sqlite_path: Path | None = None
|
|
53
|
+
object_path: Path | None = None
|
|
54
|
+
log_path: Path | None = None
|
|
55
|
+
|
|
56
|
+
logging_mode: LoggingMode = LoggingMode.HUMAN
|
|
57
|
+
log_level: str = "INFO"
|
|
58
|
+
|
|
59
|
+
max_file_bytes: int = Field(default=1_000_000, ge=1_024)
|
|
60
|
+
|
|
61
|
+
llm_provider: str | None = Field(
|
|
62
|
+
default=None,
|
|
63
|
+
description="LLM provider (openai, gemini, ollama). Leave empty for Mode A (structural only).",
|
|
64
|
+
)
|
|
65
|
+
llm_model: str | None = Field(default=None, description="LLM model.")
|
|
66
|
+
ollama_url: str = "http://127.0.0.1:11434"
|
|
67
|
+
|
|
68
|
+
mcp_host: str = "127.0.0.1"
|
|
69
|
+
mcp_port: int = 9876
|
|
70
|
+
|
|
71
|
+
daemon_poll_interval_seconds: float = 2.0
|
|
72
|
+
|
|
73
|
+
def _get_fallback_credential(self, key: str) -> str | None:
|
|
74
|
+
try:
|
|
75
|
+
cred_file = Path.home() / ".synap" / "credentials"
|
|
76
|
+
if cred_file.exists():
|
|
77
|
+
for line in cred_file.read_text().splitlines():
|
|
78
|
+
if line.strip().startswith(f"{key}="):
|
|
79
|
+
return line.split("=", 1)[1].strip().strip("\"'")
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def openai_api_key(self) -> str | None:
|
|
86
|
+
return (
|
|
87
|
+
keyring.get_password("synap", "openai_api_key")
|
|
88
|
+
or os.environ.get("SYNAP_OPENAI_API_KEY")
|
|
89
|
+
or self._get_fallback_credential("SYNAP_OPENAI_API_KEY")
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def gemini_api_key(self) -> str | None:
|
|
94
|
+
return (
|
|
95
|
+
keyring.get_password("synap", "gemini_api_key")
|
|
96
|
+
or os.environ.get("SYNAP_GEMINI_API_KEY")
|
|
97
|
+
or self._get_fallback_credential("SYNAP_GEMINI_API_KEY")
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def anthropic_api_key(self) -> str | None:
|
|
102
|
+
return (
|
|
103
|
+
keyring.get_password("synap", "anthropic_api_key")
|
|
104
|
+
or os.environ.get("SYNAP_ANTHROPIC_API_KEY")
|
|
105
|
+
or self._get_fallback_credential("SYNAP_ANTHROPIC_API_KEY")
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@field_validator("repository_path", "state_path", mode="before")
|
|
109
|
+
@classmethod
|
|
110
|
+
def _expand_path(cls, value: str | Path) -> Path:
|
|
111
|
+
return Path(value).expanduser()
|
|
112
|
+
|
|
113
|
+
@model_validator(mode="after")
|
|
114
|
+
def _derive_paths(self) -> Self:
|
|
115
|
+
repository_path = self.repository_path.resolve()
|
|
116
|
+
state_path = self.state_path
|
|
117
|
+
if not state_path.is_absolute():
|
|
118
|
+
state_path = repository_path / state_path
|
|
119
|
+
self.repository_path = repository_path
|
|
120
|
+
self.state_path = state_path.resolve()
|
|
121
|
+
self.sqlite_path = self._resolve_optional_path(self.sqlite_path, "synap.db")
|
|
122
|
+
self.object_path = self._resolve_optional_path(self.object_path, "objects")
|
|
123
|
+
self.log_path = self._resolve_optional_path(self.log_path, "logs")
|
|
124
|
+
|
|
125
|
+
if self.profile is RuntimeProfile.TEST:
|
|
126
|
+
self.logging_mode = LoggingMode.JSON
|
|
127
|
+
self.log_level = "DEBUG"
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
def _resolve_optional_path(self, value: Path | None, default_name: str) -> Path:
|
|
131
|
+
path = value or self.state_path / default_name
|
|
132
|
+
if not path.is_absolute():
|
|
133
|
+
path = self.repository_path / path
|
|
134
|
+
return path.resolve()
|
|
135
|
+
|
|
136
|
+
def ensure_directories(self) -> None:
|
|
137
|
+
self.state_path.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
if self.object_path:
|
|
139
|
+
self.object_path.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
if self.log_path:
|
|
141
|
+
self.log_path.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
|
|
143
|
+
def validate_configuration(self) -> list[str]:
|
|
144
|
+
errors = []
|
|
145
|
+
|
|
146
|
+
if self.llm_provider == "openai" and not self.openai_api_key:
|
|
147
|
+
errors.append("Missing OpenAI API key in keyring or SYNAP_OPENAI_API_KEY")
|
|
148
|
+
if self.llm_provider == "gemini" and not self.gemini_api_key:
|
|
149
|
+
errors.append("Missing Gemini API key in keyring or SYNAP_GEMINI_API_KEY")
|
|
150
|
+
if self.llm_provider == "anthropic" and not self.anthropic_api_key:
|
|
151
|
+
errors.append("Missing Anthropic API key in keyring or SYNAP_ANTHROPIC_API_KEY")
|
|
152
|
+
|
|
153
|
+
return errors
|
|
154
|
+
|
|
155
|
+
def test_connectivity(self) -> list[str]:
|
|
156
|
+
errors = []
|
|
157
|
+
from synap_git.provider.factory import get_llm_provider
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
get_llm_provider(self)
|
|
161
|
+
if self.llm_provider == "ollama":
|
|
162
|
+
import httpx
|
|
163
|
+
|
|
164
|
+
resp = httpx.get(f"{self.ollama_url}/api/tags", timeout=2.0)
|
|
165
|
+
if resp.status_code != 200:
|
|
166
|
+
errors.append(f"Ollama offline: {resp.status_code}")
|
|
167
|
+
except Exception as e:
|
|
168
|
+
errors.append(f"Provider init failed: {e}")
|
|
169
|
+
return errors
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import cast
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
import structlog
|
|
13
|
+
|
|
14
|
+
from synap_git.config import LoggingMode, SynapSettings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class TraceResult:
|
|
19
|
+
correlation_id: str
|
|
20
|
+
latency_ms: float
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def configure_logging(settings: SynapSettings) -> None:
|
|
24
|
+
timestamper = structlog.processors.TimeStamper(fmt="iso", utc=True)
|
|
25
|
+
shared_processors: list[structlog.typing.Processor] = [
|
|
26
|
+
structlog.contextvars.merge_contextvars,
|
|
27
|
+
structlog.stdlib.add_log_level,
|
|
28
|
+
timestamper,
|
|
29
|
+
structlog.processors.StackInfoRenderer(),
|
|
30
|
+
structlog.processors.format_exc_info,
|
|
31
|
+
]
|
|
32
|
+
renderer: structlog.typing.Processor
|
|
33
|
+
if settings.logging_mode is LoggingMode.JSON:
|
|
34
|
+
renderer = structlog.processors.JSONRenderer()
|
|
35
|
+
else:
|
|
36
|
+
renderer = structlog.dev.ConsoleRenderer(colors=False)
|
|
37
|
+
|
|
38
|
+
logging.basicConfig(
|
|
39
|
+
format="%(message)s",
|
|
40
|
+
stream=sys.stderr,
|
|
41
|
+
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
|
42
|
+
)
|
|
43
|
+
structlog.configure(
|
|
44
|
+
processors=[*shared_processors, renderer],
|
|
45
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
46
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
47
|
+
cache_logger_on_first_use=True,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_logger(subsystem: str) -> structlog.stdlib.BoundLogger:
|
|
52
|
+
logger = structlog.get_logger("synap").bind(subsystem=subsystem)
|
|
53
|
+
return cast(structlog.stdlib.BoundLogger, logger)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@contextlib.contextmanager
|
|
57
|
+
def traced_operation(
|
|
58
|
+
logger: structlog.stdlib.BoundLogger,
|
|
59
|
+
operation: str,
|
|
60
|
+
*,
|
|
61
|
+
object_id: str | None = None,
|
|
62
|
+
correlation_id: str | None = None,
|
|
63
|
+
**fields: object,
|
|
64
|
+
) -> Iterator[TraceResult]:
|
|
65
|
+
correlation = correlation_id or str(uuid4())
|
|
66
|
+
start = time.perf_counter()
|
|
67
|
+
bound = logger.bind(
|
|
68
|
+
operation=operation,
|
|
69
|
+
object_id=object_id,
|
|
70
|
+
correlation_id=correlation,
|
|
71
|
+
**fields,
|
|
72
|
+
)
|
|
73
|
+
bound.info("operation_started", status="started")
|
|
74
|
+
try:
|
|
75
|
+
yield TraceResult(correlation_id=correlation, latency_ms=0.0)
|
|
76
|
+
except Exception:
|
|
77
|
+
latency_ms = (time.perf_counter() - start) * 1000
|
|
78
|
+
bound.exception("operation_failed", status="error", latency=latency_ms)
|
|
79
|
+
raise
|
|
80
|
+
else:
|
|
81
|
+
latency_ms = (time.perf_counter() - start) * 1000
|
|
82
|
+
bound.info("operation_finished", status="ok", latency=latency_ms)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TraceStore:
|
|
10
|
+
"""Manages recording and retrieving the latest operational trace."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, repository_path: Path) -> None:
|
|
13
|
+
self.repo_path = repository_path
|
|
14
|
+
self.trace_file = repository_path / ".synap" / "trace_latest.json"
|
|
15
|
+
|
|
16
|
+
def record_trace(
|
|
17
|
+
self,
|
|
18
|
+
trace_type: str,
|
|
19
|
+
summary: str,
|
|
20
|
+
details: dict[str, Any],
|
|
21
|
+
) -> dict[str, Any]:
|
|
22
|
+
"""Save a new trace event as the latest trace."""
|
|
23
|
+
import uuid
|
|
24
|
+
|
|
25
|
+
trace_id = str(uuid.uuid4())
|
|
26
|
+
trace_data = {
|
|
27
|
+
"trace_id": trace_id,
|
|
28
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
29
|
+
"type": trace_type,
|
|
30
|
+
"summary": summary,
|
|
31
|
+
"details": details,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
self.trace_file.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
self.trace_file.write_text(json.dumps(trace_data, indent=2), encoding="utf-8")
|
|
37
|
+
except Exception:
|
|
38
|
+
# Operational stability: do not crash the app if trace file cannot be written
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
return trace_data
|
|
42
|
+
|
|
43
|
+
def get_latest(self) -> dict[str, Any]:
|
|
44
|
+
"""Retrieve the latest trace event."""
|
|
45
|
+
if not self.trace_file.exists():
|
|
46
|
+
return {
|
|
47
|
+
"trace_id": "none",
|
|
48
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
49
|
+
"type": "system",
|
|
50
|
+
"summary": "No traces recorded yet",
|
|
51
|
+
"details": {
|
|
52
|
+
"status": "ready",
|
|
53
|
+
"message": "Tracing system initialized. Waiting for retrieval/indexing activity.",
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
data = json.loads(self.trace_file.read_text(encoding="utf-8"))
|
|
59
|
+
if isinstance(data, dict):
|
|
60
|
+
import typing
|
|
61
|
+
|
|
62
|
+
return typing.cast(dict[str, Any], data)
|
|
63
|
+
return {"error": "Invalid trace structure"}
|
|
64
|
+
except Exception as e:
|
|
65
|
+
return {
|
|
66
|
+
"trace_id": "error",
|
|
67
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
68
|
+
"type": "error",
|
|
69
|
+
"summary": "Failed to read latest trace",
|
|
70
|
+
"details": {"error": str(e)},
|
|
71
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Git state inspection for context commits."""
|
|
2
|
+
|
|
3
|
+
from synap_git.git.state import (
|
|
4
|
+
GitChange,
|
|
5
|
+
GitChangeKind,
|
|
6
|
+
GitIntegrationError,
|
|
7
|
+
GitRepository,
|
|
8
|
+
GitState,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = ["GitChange", "GitChangeKind", "GitIntegrationError", "GitRepository", "GitState"]
|
synap_git/git/state.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitIntegrationError(RuntimeError):
|
|
10
|
+
"""Raised when Git state cannot be inspected."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GitChangeKind(StrEnum):
|
|
14
|
+
INITIAL = "initial"
|
|
15
|
+
COMMIT = "commit"
|
|
16
|
+
CHECKOUT = "checkout"
|
|
17
|
+
BRANCH = "branch"
|
|
18
|
+
MERGE = "merge"
|
|
19
|
+
REBASE = "rebase"
|
|
20
|
+
REVERT = "revert"
|
|
21
|
+
UNCHANGED = "unchanged"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class GitState:
|
|
26
|
+
repository_path: Path
|
|
27
|
+
is_repository: bool
|
|
28
|
+
head_commit: str | None = None
|
|
29
|
+
branch: str | None = None
|
|
30
|
+
is_dirty: bool = False
|
|
31
|
+
is_detached: bool = False
|
|
32
|
+
merge_in_progress: bool = False
|
|
33
|
+
rebase_in_progress: bool = False
|
|
34
|
+
commit_parent_hashes: tuple[str, ...] = ()
|
|
35
|
+
commit_message: str | None = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def effective_branch(self) -> str:
|
|
39
|
+
return self.branch or "detached"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class GitChange:
|
|
44
|
+
kind: GitChangeKind
|
|
45
|
+
previous: GitState | None
|
|
46
|
+
current: GitState
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GitRepository:
|
|
50
|
+
"""Thin GitPython-backed reader for repository lineage state."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, path: Path) -> None:
|
|
53
|
+
self.path = path
|
|
54
|
+
|
|
55
|
+
def state(self) -> GitState:
|
|
56
|
+
try:
|
|
57
|
+
from git import InvalidGitRepositoryError, NoSuchPathError, Repo
|
|
58
|
+
except ImportError as exc:
|
|
59
|
+
raise GitIntegrationError("GitPython is not installed") from exc
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
repo: Any = Repo(self.path, search_parent_directories=True)
|
|
63
|
+
except (InvalidGitRepositoryError, NoSuchPathError):
|
|
64
|
+
return GitState(repository_path=self.path.resolve(), is_repository=False)
|
|
65
|
+
|
|
66
|
+
repository_path = Path(repo.working_tree_dir or self.path).resolve()
|
|
67
|
+
git_dir = Path(repo.git_dir)
|
|
68
|
+
is_detached = bool(repo.head.is_detached)
|
|
69
|
+
branch: str | None
|
|
70
|
+
if is_detached:
|
|
71
|
+
branch = None
|
|
72
|
+
else:
|
|
73
|
+
branch = str(repo.active_branch.name)
|
|
74
|
+
|
|
75
|
+
head_commit: str | None = None
|
|
76
|
+
parent_hashes: tuple[str, ...] = ()
|
|
77
|
+
commit_message: str | None = None
|
|
78
|
+
try:
|
|
79
|
+
commit = repo.head.commit
|
|
80
|
+
head_commit = str(commit.hexsha)
|
|
81
|
+
parent_hashes = tuple(str(parent.hexsha) for parent in commit.parents)
|
|
82
|
+
commit_message = str(commit.message).strip()
|
|
83
|
+
except ValueError:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
return GitState(
|
|
87
|
+
repository_path=repository_path,
|
|
88
|
+
is_repository=True,
|
|
89
|
+
head_commit=head_commit,
|
|
90
|
+
branch=branch,
|
|
91
|
+
is_dirty=bool(repo.is_dirty(untracked_files=True)),
|
|
92
|
+
is_detached=is_detached,
|
|
93
|
+
merge_in_progress=(git_dir / "MERGE_HEAD").exists(),
|
|
94
|
+
rebase_in_progress=(git_dir / "rebase-merge").exists()
|
|
95
|
+
or (git_dir / "rebase-apply").exists(),
|
|
96
|
+
commit_parent_hashes=parent_hashes,
|
|
97
|
+
commit_message=commit_message,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def classify(previous: GitState | None, current: GitState) -> GitChange:
|
|
102
|
+
if previous is None:
|
|
103
|
+
return GitChange(kind=GitChangeKind.INITIAL, previous=None, current=current)
|
|
104
|
+
if current.rebase_in_progress and not previous.rebase_in_progress:
|
|
105
|
+
return GitChange(kind=GitChangeKind.REBASE, previous=previous, current=current)
|
|
106
|
+
if current.merge_in_progress and not previous.merge_in_progress:
|
|
107
|
+
return GitChange(kind=GitChangeKind.MERGE, previous=previous, current=current)
|
|
108
|
+
if previous.branch != current.branch:
|
|
109
|
+
return GitChange(kind=GitChangeKind.BRANCH, previous=previous, current=current)
|
|
110
|
+
if previous.head_commit != current.head_commit:
|
|
111
|
+
message = current.commit_message or ""
|
|
112
|
+
if message.startswith("Revert "):
|
|
113
|
+
return GitChange(kind=GitChangeKind.REVERT, previous=previous, current=current)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
from git import Repo
|
|
117
|
+
|
|
118
|
+
repo = Repo(current.repository_path)
|
|
119
|
+
curr_commit = repo.head.commit
|
|
120
|
+
curr_tree = curr_commit.tree.hexsha
|
|
121
|
+
is_revert = False
|
|
122
|
+
for parent in curr_commit.parents:
|
|
123
|
+
for ancestor in repo.iter_commits(parent, max_count=20):
|
|
124
|
+
if ancestor.tree.hexsha == curr_tree:
|
|
125
|
+
is_revert = True
|
|
126
|
+
break
|
|
127
|
+
if is_revert:
|
|
128
|
+
break
|
|
129
|
+
if is_revert:
|
|
130
|
+
return GitChange(kind=GitChangeKind.REVERT, previous=previous, current=current)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
if len(current.commit_parent_hashes) > 1:
|
|
135
|
+
return GitChange(kind=GitChangeKind.MERGE, previous=previous, current=current)
|
|
136
|
+
return GitChange(kind=GitChangeKind.COMMIT, previous=previous, current=current)
|
|
137
|
+
if previous.is_detached != current.is_detached:
|
|
138
|
+
return GitChange(kind=GitChangeKind.CHECKOUT, previous=previous, current=current)
|
|
139
|
+
return GitChange(kind=GitChangeKind.UNCHANGED, previous=previous, current=current)
|
|
File without changes
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import signal
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from synap_git.config import SynapSettings
|
|
9
|
+
from synap_git.diagnostics.logger import get_logger
|
|
10
|
+
from synap_git.git import GitRepository, GitState
|
|
11
|
+
from synap_git.indexer.engine import SynapRuntime
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class DaemonHealth:
|
|
16
|
+
running: bool
|
|
17
|
+
active_commit: str | None
|
|
18
|
+
branch: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RuntimeDaemon:
|
|
22
|
+
"""Simplified long-running local daemon with Git-aware indexing."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, settings: SynapSettings) -> None:
|
|
25
|
+
self.settings = settings
|
|
26
|
+
self.runtime = SynapRuntime(settings)
|
|
27
|
+
self.git = GitRepository(settings.repository_path)
|
|
28
|
+
self.logger = get_logger("daemon")
|
|
29
|
+
self._stop_event = asyncio.Event()
|
|
30
|
+
self._last_git_state: GitState | None = None
|
|
31
|
+
self._running = False
|
|
32
|
+
|
|
33
|
+
async def start(self) -> None:
|
|
34
|
+
self.runtime.initialize_storage()
|
|
35
|
+
self._uptime_start = time.time()
|
|
36
|
+
self._recovery_attempts = 0
|
|
37
|
+
|
|
38
|
+
# Initial bootstrap
|
|
39
|
+
await asyncio.to_thread(self.runtime.bootstrap)
|
|
40
|
+
|
|
41
|
+
self._running = True
|
|
42
|
+
self._write_heartbeat(status="healthy")
|
|
43
|
+
self._install_signal_handlers()
|
|
44
|
+
try:
|
|
45
|
+
await self._poll_git_loop()
|
|
46
|
+
finally:
|
|
47
|
+
self._running = False
|
|
48
|
+
self._delete_heartbeat()
|
|
49
|
+
|
|
50
|
+
def stop(self) -> None:
|
|
51
|
+
self._stop_event.set()
|
|
52
|
+
|
|
53
|
+
def health(self) -> DaemonHealth:
|
|
54
|
+
status = self.runtime.status()
|
|
55
|
+
return DaemonHealth(
|
|
56
|
+
running=self._running,
|
|
57
|
+
active_commit=status.active_commit,
|
|
58
|
+
branch=status.branch,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def _write_heartbeat(self, status: str = "healthy", last_error: str | None = None) -> None:
|
|
62
|
+
import json
|
|
63
|
+
import os
|
|
64
|
+
import time as time_lib
|
|
65
|
+
from datetime import UTC, datetime
|
|
66
|
+
|
|
67
|
+
heartbeat_file = self.settings.repository_path / ".synap" / "daemon_heartbeat.json"
|
|
68
|
+
heartbeat_file.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
uptime = int(time_lib.time() - self._uptime_start) if hasattr(self, "_uptime_start") else 0
|
|
71
|
+
|
|
72
|
+
data = {
|
|
73
|
+
"pid": os.getpid(),
|
|
74
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
75
|
+
"status": status,
|
|
76
|
+
"uptime_seconds": uptime,
|
|
77
|
+
"recovery_attempts": getattr(self, "_recovery_attempts", 0),
|
|
78
|
+
"last_error": last_error,
|
|
79
|
+
"branch": self._last_git_state.effective_branch if self._last_git_state else "unknown",
|
|
80
|
+
"active_commit": self._last_git_state.head_commit
|
|
81
|
+
if self._last_git_state
|
|
82
|
+
else "unknown",
|
|
83
|
+
}
|
|
84
|
+
try:
|
|
85
|
+
heartbeat_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
def _delete_heartbeat(self) -> None:
|
|
90
|
+
heartbeat_file = self.settings.repository_path / ".synap" / "daemon_heartbeat.json"
|
|
91
|
+
if heartbeat_file.exists():
|
|
92
|
+
try:
|
|
93
|
+
heartbeat_file.unlink()
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
async def _poll_git_loop(self) -> None:
|
|
98
|
+
while not self._stop_event.is_set():
|
|
99
|
+
try:
|
|
100
|
+
state = self.git.state()
|
|
101
|
+
change = self.git.classify(self._last_git_state, state)
|
|
102
|
+
self._last_git_state = state
|
|
103
|
+
|
|
104
|
+
if change.kind == "commit":
|
|
105
|
+
self.logger.info("git_commit_detected")
|
|
106
|
+
await asyncio.to_thread(self.runtime.handle_commit, state)
|
|
107
|
+
elif change.kind in ("branch", "checkout"):
|
|
108
|
+
self.logger.info("git_branch_switch_detected")
|
|
109
|
+
await asyncio.to_thread(self.runtime.handle_branch_switch, state)
|
|
110
|
+
elif change.kind == "merge":
|
|
111
|
+
self.logger.info("git_merge_detected")
|
|
112
|
+
await asyncio.to_thread(self.runtime.handle_merge, state)
|
|
113
|
+
elif change.kind == "revert":
|
|
114
|
+
self.logger.info("git_revert_detected")
|
|
115
|
+
await asyncio.to_thread(self.runtime.handle_revert, self._last_git_state, state)
|
|
116
|
+
elif change.kind not in ("initial", "unchanged"):
|
|
117
|
+
self.logger.info("git_change_detected", kind=change.kind)
|
|
118
|
+
await asyncio.to_thread(self.runtime.index_repository, git_state=state)
|
|
119
|
+
|
|
120
|
+
self._write_heartbeat(status="healthy")
|
|
121
|
+
except Exception as e:
|
|
122
|
+
self.logger.error("daemon_loop_error", error=str(e))
|
|
123
|
+
self._recovery_attempts += 1
|
|
124
|
+
self._write_heartbeat(status="degraded", last_error=str(e))
|
|
125
|
+
|
|
126
|
+
# Database recovery if corrupted
|
|
127
|
+
try:
|
|
128
|
+
is_wiped = await asyncio.to_thread(self.runtime.store.recover_if_corrupted)
|
|
129
|
+
if is_wiped:
|
|
130
|
+
self.logger.warning("daemon_rebuilding_corrupted_db")
|
|
131
|
+
await asyncio.to_thread(self.runtime.bootstrap, force=True)
|
|
132
|
+
except Exception as ex:
|
|
133
|
+
self.logger.error("daemon_self_healing_failed", error=str(ex))
|
|
134
|
+
|
|
135
|
+
await asyncio.sleep(self.settings.daemon_poll_interval_seconds)
|
|
136
|
+
|
|
137
|
+
def _install_signal_handlers(self) -> None:
|
|
138
|
+
loop = asyncio.get_running_loop()
|
|
139
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
140
|
+
try:
|
|
141
|
+
loop.add_signal_handler(sig, self.stop)
|
|
142
|
+
except NotImplementedError:
|
|
143
|
+
continue
|