codrspot-processor-mcp 0.1.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.
- codepreproc_client/__init__.py +14 -0
- codepreproc_client/__main__.py +9 -0
- codepreproc_client/layer1_business/__init__.py +0 -0
- codepreproc_client/layer1_business/allowed_scope_cache.py +62 -0
- codepreproc_client/layer1_business/api_client/__init__.py +0 -0
- codepreproc_client/layer1_business/api_client/lease_client.py +34 -0
- codepreproc_client/layer1_business/api_client/net_guard.py +98 -0
- codepreproc_client/layer1_business/api_client/promote_client.py +59 -0
- codepreproc_client/layer1_business/api_client/reasoning_client.py +163 -0
- codepreproc_client/layer1_business/api_client/registry_client.py +119 -0
- codepreproc_client/layer1_business/api_client/superkg_client.py +327 -0
- codepreproc_client/layer1_business/cli/__init__.py +0 -0
- codepreproc_client/layer1_business/cli/init.py +578 -0
- codepreproc_client/layer1_business/cli/main.py +78 -0
- codepreproc_client/layer1_business/config.py +104 -0
- codepreproc_client/layer1_business/git_utils.py +67 -0
- codepreproc_client/layer1_business/license/__init__.py +0 -0
- codepreproc_client/layer1_business/license/fingerprint.py +61 -0
- codepreproc_client/layer1_business/license/jwt_client.py +60 -0
- codepreproc_client/layer1_business/license/project_lock.py +44 -0
- codepreproc_client/layer1_business/local_registry.py +40 -0
- codepreproc_client/layer1_business/manifest/__init__.py +0 -0
- codepreproc_client/layer1_business/manifest/credentials.py +35 -0
- codepreproc_client/layer1_business/mcp/__init__.py +0 -0
- codepreproc_client/layer1_business/mcp/action_monitor.py +246 -0
- codepreproc_client/layer1_business/mcp/lifecycle.py +649 -0
- codepreproc_client/layer1_business/mcp/server.py +656 -0
- codepreproc_client/layer1_business/mcp/tools.py +1665 -0
- codepreproc_client/layer1_business/project_registry.py +99 -0
- codepreproc_client/layer2_tooling/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/ast_graph.py +426 -0
- codepreproc_client/layer2_tooling/condensation/bm25_store.py +132 -0
- codepreproc_client/layer2_tooling/condensation/chunking/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/chunking/ast_chunker.py +313 -0
- codepreproc_client/layer2_tooling/condensation/chunking/languages.py +238 -0
- codepreproc_client/layer2_tooling/condensation/embed_worker.py +128 -0
- codepreproc_client/layer2_tooling/condensation/embeddings.py +452 -0
- codepreproc_client/layer2_tooling/condensation/extract/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/extract/compactor.py +103 -0
- codepreproc_client/layer2_tooling/condensation/extract/providers/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/extract/providers/base.py +38 -0
- codepreproc_client/layer2_tooling/condensation/indexer.py +945 -0
- codepreproc_client/layer2_tooling/condensation/ppl/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/ppl/emitter.py +162 -0
- codepreproc_client/layer2_tooling/condensation/ppl/parser.py +352 -0
- codepreproc_client/layer2_tooling/condensation/qdrant_store.py +387 -0
- codepreproc_client/layer2_tooling/condensation/scanning/__init__.py +0 -0
- codepreproc_client/layer2_tooling/condensation/scanning/manifest_scanner.py +72 -0
- codepreproc_client/layer2_tooling/condensation/scanning/md_scanner.py +97 -0
- codepreproc_client/layer2_tooling/condensation/scanning/repo_mapper.py +495 -0
- codepreproc_client/layer2_tooling/condensation/session_vector_store.py +69 -0
- codepreproc_client/layer2_tooling/coupling/__init__.py +0 -0
- codepreproc_client/layer2_tooling/coupling/domain.py +263 -0
- codepreproc_client/layer2_tooling/coupling/graph.py +255 -0
- codepreproc_client/layer2_tooling/coupling/layer2_gate.py +189 -0
- codepreproc_client/layer2_tooling/coupling/projector.py +139 -0
- codepreproc_client/layer2_tooling/coupling/resolver.py +150 -0
- codepreproc_client/layer2_tooling/materialization/__init__.py +0 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/__init__.py +0 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/execute_semantic.py +1249 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/filesystem_reorg.py +575 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/patch_generator.py +127 -0
- codepreproc_client/layer2_tooling/materialization/pipeline/patch_validator.py +101 -0
- codepreproc_client/layer2_tooling/materialization/semantic/__init__.py +19 -0
- codepreproc_client/layer2_tooling/materialization/semantic/anchors.py +61 -0
- codepreproc_client/layer2_tooling/materialization/semantic/applicability.py +75 -0
- codepreproc_client/layer2_tooling/materialization/semantic/edit_planner.py +264 -0
- codepreproc_client/layer2_tooling/materialization/semantic/materializer.py +121 -0
- codepreproc_client/layer2_tooling/materialization/semantic/merger.py +73 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/__init__.py +0 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/add_import.py +36 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/append_argument.py +37 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/insert_literal.py +24 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_import.py +33 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_statement_unique.py +14 -0
- codepreproc_client/layer2_tooling/materialization/semantic/ops/rename_symbol_local.py +16 -0
- codepreproc_client/layer2_tooling/materialization/semantic/region_resolver.py +229 -0
- codepreproc_client/layer2_tooling/materialization/semantic/synthesizer.py +186 -0
- codepreproc_client/layer2_tooling/materialization/semantic/target_locator.py +94 -0
- codepreproc_client/layer2_tooling/materialization/semantic/validator.py +322 -0
- codepreproc_client/layer2_tooling/materialization/snippet/__init__.py +0 -0
- codepreproc_client/layer2_tooling/materialization/snippet/assembler.py +503 -0
- codepreproc_client/layer2_tooling/materialization/snippet/loader.py +187 -0
- codepreproc_client/layer2_tooling/retrieval/__init__.py +0 -0
- codepreproc_client/layer2_tooling/retrieval/graph_walk.py +47 -0
- codepreproc_client/layer2_tooling/retrieval/hybrid.py +95 -0
- codepreproc_client/layer2_tooling/retrieval/rerank_worker.py +86 -0
- codepreproc_client/layer2_tooling/retrieval/reranker.py +213 -0
- codepreproc_client/layer2_tooling/watcher/__init__.py +0 -0
- codepreproc_client/layer2_tooling/watcher/fs_watcher.py +3 -0
- codrspot_processor_mcp-0.1.0.dist-info/METADATA +396 -0
- codrspot_processor_mcp-0.1.0.dist-info/RECORD +100 -0
- codrspot_processor_mcp-0.1.0.dist-info/WHEEL +5 -0
- codrspot_processor_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- codrspot_processor_mcp-0.1.0.dist-info/licenses/LICENSE +23 -0
- codrspot_processor_mcp-0.1.0.dist-info/top_level.txt +2 -0
- contracts/__init__.py +0 -0
- contracts/dtos.py +1239 -0
- contracts/ir.py +102 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Configuration loading helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from contracts.dtos import LLMProviderConfig, Registry, Settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def apply_env_overrides(registry: Registry) -> Registry:
|
|
12
|
+
"""Apply CODEPREPROC_* environment variables on top of a Registry loaded from PostgreSQL."""
|
|
13
|
+
d = registry.defaults
|
|
14
|
+
provider_data = {name: cfg.model_dump(mode="python") for name, cfg in d.llm_providers.items()}
|
|
15
|
+
|
|
16
|
+
if "CODEPREPROC_LLM_ENDPOINT" in os.environ or "CODEPREPROC_LLM_MODEL" in os.environ:
|
|
17
|
+
local = provider_data.get("local_qwen", {"type": "llamacpp"})
|
|
18
|
+
local["endpoint"] = os.getenv("CODEPREPROC_LLM_ENDPOINT", local.get("endpoint", d.llm_endpoint))
|
|
19
|
+
local["model"] = os.getenv("CODEPREPROC_LLM_MODEL", local.get("model", d.llm_model))
|
|
20
|
+
provider_data["local_qwen"] = local
|
|
21
|
+
|
|
22
|
+
if "CODEPREPROC_OPENAI_MODEL" in os.environ or "CODEPREPROC_OPENAI_API_KEY_ENV" in os.environ:
|
|
23
|
+
remote = provider_data.get(
|
|
24
|
+
"openai_4o_mini",
|
|
25
|
+
{"type": "openai", "model": "gpt-4o-mini", "api_key_env": "OPENAI_API_KEY"},
|
|
26
|
+
)
|
|
27
|
+
remote["model"] = os.getenv("CODEPREPROC_OPENAI_MODEL", remote.get("model", "gpt-4o-mini"))
|
|
28
|
+
remote["api_key_env"] = os.getenv(
|
|
29
|
+
"CODEPREPROC_OPENAI_API_KEY_ENV", remote.get("api_key_env", "OPENAI_API_KEY")
|
|
30
|
+
)
|
|
31
|
+
provider_data["openai_4o_mini"] = remote
|
|
32
|
+
|
|
33
|
+
new_defaults = d.model_copy(
|
|
34
|
+
update={
|
|
35
|
+
"embedding_model": os.getenv("CODEPREPROC_EMBEDDING_MODEL", d.embedding_model),
|
|
36
|
+
"reranker_model": os.getenv("CODEPREPROC_RERANKER_MODEL", d.reranker_model),
|
|
37
|
+
"llm_endpoint": os.getenv("CODEPREPROC_LLM_ENDPOINT", d.llm_endpoint),
|
|
38
|
+
"llm_model": os.getenv("CODEPREPROC_LLM_MODEL", d.llm_model),
|
|
39
|
+
"default_llm_policy": os.getenv("CODEPREPROC_DEFAULT_LLM_POLICY", d.default_llm_policy),
|
|
40
|
+
"llm_providers": {name: LLMProviderConfig.model_validate(item) for name, item in provider_data.items()},
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
return Registry(defaults=new_defaults, projects=registry.projects)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_superkg_settings() -> tuple[bool, float]:
|
|
47
|
+
"""Return (enabled, enrich_timeout_s) for SuperKG proxy calls through
|
|
48
|
+
codepreproc-server (POST /v1/superkg/*). The client never holds the real
|
|
49
|
+
SuperKG URL/credentials - only codepreproc-server does
|
|
50
|
+
(layer3_core/superkg/connector.py, server-side SUPERKG_URL)."""
|
|
51
|
+
enabled = _env_bool("SUPERKG_ENABLED", default=True)
|
|
52
|
+
enrich_timeout_ms = int(os.getenv("SUPERKG_ENRICH_TIMEOUT_MS") or "800")
|
|
53
|
+
return enabled, enrich_timeout_ms / 1000.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _env_bool(name: str, default: bool = False) -> bool:
|
|
57
|
+
raw = os.getenv(name)
|
|
58
|
+
if raw is None:
|
|
59
|
+
return default
|
|
60
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _env_connection_verbosity(name: str, default: str = "basic") -> str:
|
|
64
|
+
raw = os.getenv(name)
|
|
65
|
+
if raw is None:
|
|
66
|
+
return default
|
|
67
|
+
value = raw.strip().lower()
|
|
68
|
+
if value in {"off", "basic", "verbose"}:
|
|
69
|
+
return value
|
|
70
|
+
return default
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_server_url() -> str:
|
|
74
|
+
"""Base URL of the codepreproc-server API (contracts/openapi.yaml)."""
|
|
75
|
+
|
|
76
|
+
return os.getenv("CODEPREPROC_SERVER_URL", "https://localhost:8443")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_credentials_path() -> Path:
|
|
80
|
+
"""Path to credentials.yaml (the manifest), used only at mint time."""
|
|
81
|
+
|
|
82
|
+
return Path(os.getenv("CODEPREPROC_CREDENTIALS", str(get_home_dir() / "credentials.yaml")))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_home_dir() -> Path:
|
|
86
|
+
"""Return the codepreproc home directory."""
|
|
87
|
+
|
|
88
|
+
return Path(os.getenv("CODEPREPROC_HOME", Path.home() / ".codepreproc")).resolve()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_settings() -> Settings:
|
|
92
|
+
"""Build runtime settings."""
|
|
93
|
+
|
|
94
|
+
home_dir = get_home_dir()
|
|
95
|
+
indexes_dir = Path(os.getenv("CODEPREPROC_INDEXES_DIR", home_dir / "indexes")).resolve()
|
|
96
|
+
logs_dir = Path(os.getenv("CODEPREPROC_LOGS_DIR", home_dir / "logs")).resolve()
|
|
97
|
+
|
|
98
|
+
return Settings(
|
|
99
|
+
home_dir=home_dir,
|
|
100
|
+
indexes_dir=indexes_dir,
|
|
101
|
+
logs_dir=logs_dir,
|
|
102
|
+
debug=_env_bool("CODEPREPROC_DEBUG"),
|
|
103
|
+
mcp_connection_verbosity=_env_connection_verbosity("CODEPREPROC_MCP_CONNECTION_VERBOSITY"),
|
|
104
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Git helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from git import InvalidGitRepositoryError, NoSuchPathError, Repo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_repo_root(path: Path) -> Path | None:
|
|
11
|
+
"""Return git root for a path, if any."""
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
return Path(Repo(path, search_parent_directories=True).working_tree_dir or "").resolve()
|
|
15
|
+
except (InvalidGitRepositoryError, NoSuchPathError):
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_current_sha(repo: Repo) -> str:
|
|
20
|
+
"""Return HEAD SHA."""
|
|
21
|
+
|
|
22
|
+
return repo.head.commit.hexsha
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_current_branch(repo: Repo) -> str:
|
|
26
|
+
"""Return current branch name or detached marker."""
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
return repo.active_branch.name
|
|
30
|
+
except TypeError:
|
|
31
|
+
return "DETACHED"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_remote_url(repo: Repo) -> str | None:
|
|
35
|
+
"""Return origin URL if available."""
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
return next(iter(repo.remotes)).url
|
|
39
|
+
except (StopIteration, AttributeError):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_dirty_files(repo: Repo) -> list[Path]:
|
|
44
|
+
"""Return modified and untracked files relative to repo root."""
|
|
45
|
+
|
|
46
|
+
root = Path(repo.working_tree_dir or ".").resolve()
|
|
47
|
+
dirty = {root / item.a_path for item in repo.index.diff(None)}
|
|
48
|
+
dirty.update(root / path for path in repo.untracked_files)
|
|
49
|
+
return sorted(path.resolve() for path in dirty if path.exists() and path.is_file())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def diff_files_between_sha(repo: Repo, old_sha: str, new_sha: str) -> list[Path]:
|
|
53
|
+
"""Return changed files between two SHAs."""
|
|
54
|
+
|
|
55
|
+
root = Path(repo.working_tree_dir or ".").resolve()
|
|
56
|
+
diff_items = repo.commit(old_sha).diff(new_sha)
|
|
57
|
+
files = {root / item.a_path for item in diff_items if item.a_path}
|
|
58
|
+
files.update(root / item.b_path for item in diff_items if item.b_path)
|
|
59
|
+
return sorted(path.resolve() for path in files if path.exists() and path.is_file())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def normalize_content(text: str) -> str:
|
|
63
|
+
"""Normalize line endings and BOM for consistent hashing."""
|
|
64
|
+
|
|
65
|
+
if text.startswith("\ufeff"):
|
|
66
|
+
text = text.removeprefix("\ufeff")
|
|
67
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Derive a project fingerprint from git identity (architecture doc S8).
|
|
2
|
+
|
|
3
|
+
Fingerprints repo *identity*, not *state*. Two methods:
|
|
4
|
+
|
|
5
|
+
- `project_lock_commit` (robust, recommended for regulated verticals):
|
|
6
|
+
hash of the committed `.codepreproc/project.lock` id. Mutates the repo,
|
|
7
|
+
but survives rebase/squash/filter-repo since it's just tracked content.
|
|
8
|
+
- `early_history_sample` (non-invasive): hash of the repo's earliest
|
|
9
|
+
commit SHAs. Doesn't mutate the repo, but is fragile to history rewrite
|
|
10
|
+
and shallow clones.
|
|
11
|
+
|
|
12
|
+
The local check here is a speed-bump against *accident*; the real gate is
|
|
13
|
+
the server re-deriving and comparing at promote time
|
|
14
|
+
(layer1_authority/fingerprint_registry.py) - this module never asserts
|
|
15
|
+
its own output is trustworthy.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from git import Repo
|
|
24
|
+
|
|
25
|
+
from contracts.dtos import FingerprintAttestation
|
|
26
|
+
from codepreproc_client.layer1_business.license.project_lock import read_project_lock
|
|
27
|
+
|
|
28
|
+
EARLY_HISTORY_SAMPLE_SIZE = 5
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _hash(*parts: str) -> str:
|
|
32
|
+
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _sample_early_commit_shas(repo: Repo) -> list[str]:
|
|
36
|
+
commits = list(repo.iter_commits(reverse=True))
|
|
37
|
+
return [c.hexsha for c in commits[:EARLY_HISTORY_SAMPLE_SIZE]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def derive_fingerprint(project_id: str, repo_root: Path) -> FingerprintAttestation:
|
|
41
|
+
"""Derive a `FingerprintAttestation` for `repo_root`, preferring the robust method."""
|
|
42
|
+
|
|
43
|
+
repo = Repo(repo_root)
|
|
44
|
+
|
|
45
|
+
lock_id = read_project_lock(repo_root)
|
|
46
|
+
if lock_id:
|
|
47
|
+
return FingerprintAttestation(
|
|
48
|
+
project_id=project_id,
|
|
49
|
+
fingerprint_hash=_hash("project_lock", lock_id),
|
|
50
|
+
method="project_lock_commit",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
sampled_shas = _sample_early_commit_shas(repo)
|
|
54
|
+
if not sampled_shas:
|
|
55
|
+
raise RuntimeError(f"cannot_derive_fingerprint_no_commits: {repo_root}")
|
|
56
|
+
return FingerprintAttestation(
|
|
57
|
+
project_id=project_id,
|
|
58
|
+
fingerprint_hash=_hash("early_history", *sampled_shas),
|
|
59
|
+
method="early_history_sample",
|
|
60
|
+
sampled_commit_shas=sampled_shas,
|
|
61
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Obtain/refresh the 1h JWT lease and attach it as a bearer token (architecture doc S6).
|
|
2
|
+
|
|
3
|
+
`credentials.yaml` is read once, at mint time. The JWT itself is the
|
|
4
|
+
offline lease: the client can keep operating up to 1h without re-minting,
|
|
5
|
+
so a network blip shorter than that survives transparently.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from contracts.dtos import FingerprintAttestation, Manifest
|
|
15
|
+
|
|
16
|
+
DEFAULT_TIMEOUT_S = 15.0
|
|
17
|
+
# Refresh a little before actual expiry so an in-flight call never races the deadline.
|
|
18
|
+
REFRESH_SKEW_S = 30.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MintRefusedError(RuntimeError):
|
|
22
|
+
"""Raised when the server refuses to mint (seat/project quota exceeded, invalid manifest)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class JWTClient:
|
|
26
|
+
"""Caches the current JWT and transparently re-mints it once it's near expiry."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, *, base_url: str, manifest: Manifest, fingerprint_attestation: FingerprintAttestation) -> None:
|
|
29
|
+
self._base_url = base_url
|
|
30
|
+
self._manifest = manifest
|
|
31
|
+
self._fingerprint_attestation = fingerprint_attestation
|
|
32
|
+
self._jwt: str | None = None
|
|
33
|
+
self._expires_at: datetime | None = None
|
|
34
|
+
|
|
35
|
+
async def get_bearer_token(self) -> str:
|
|
36
|
+
"""Return a valid JWT, minting or refreshing it first if necessary."""
|
|
37
|
+
|
|
38
|
+
if self._jwt is None or self._is_near_expiry():
|
|
39
|
+
await self._mint()
|
|
40
|
+
return self._jwt # type: ignore[return-value]
|
|
41
|
+
|
|
42
|
+
def _is_near_expiry(self) -> bool:
|
|
43
|
+
if self._expires_at is None:
|
|
44
|
+
return True
|
|
45
|
+
remaining = (self._expires_at - datetime.now(timezone.utc)).total_seconds()
|
|
46
|
+
return remaining <= REFRESH_SKEW_S
|
|
47
|
+
|
|
48
|
+
async def _mint(self) -> None:
|
|
49
|
+
request_body = {
|
|
50
|
+
"manifest": self._manifest.model_dump(mode="json"),
|
|
51
|
+
"fingerprint_attestation": self._fingerprint_attestation.model_dump(mode="json"),
|
|
52
|
+
}
|
|
53
|
+
async with httpx.AsyncClient(base_url=self._base_url, timeout=DEFAULT_TIMEOUT_S) as client:
|
|
54
|
+
response = await client.post("/v1/mint", json=request_body)
|
|
55
|
+
if response.status_code == 403:
|
|
56
|
+
raise MintRefusedError(response.json().get("message", "mint_refused"))
|
|
57
|
+
response.raise_for_status()
|
|
58
|
+
body = response.json()
|
|
59
|
+
self._jwt = body["jwt"]
|
|
60
|
+
self._expires_at = datetime.fromisoformat(body["expires_at"])
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Read/write .codepreproc/project.lock (architecture doc S8, robust method).
|
|
2
|
+
|
|
3
|
+
Injects a legitimate tooling artifact into the client's repo (a small
|
|
4
|
+
committed file whose hash the server controls the meaning of) so project
|
|
5
|
+
identity survives history rewrites. This mutates the client's repo, which
|
|
6
|
+
is why it's opt-in per the "robust" vs "non-invasive" fingerprint choice
|
|
7
|
+
in fingerprint.py.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
LOCK_RELATIVE_PATH = Path(".codepreproc") / "project.lock"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_project_lock(repo_root: Path) -> str | None:
|
|
19
|
+
"""Return the lock id in `<repo_root>/.codepreproc/project.lock`, or None if absent."""
|
|
20
|
+
|
|
21
|
+
lock_path = repo_root / LOCK_RELATIVE_PATH
|
|
22
|
+
if not lock_path.is_file():
|
|
23
|
+
return None
|
|
24
|
+
content = lock_path.read_text(encoding="utf-8").strip()
|
|
25
|
+
return content or None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_project_lock(repo_root: Path, lock_id: str | None = None) -> str:
|
|
29
|
+
"""Write a new project.lock file (if one doesn't already exist) and return its lock id.
|
|
30
|
+
|
|
31
|
+
Does not commit the file - the caller is expected to `git add` and
|
|
32
|
+
commit it as part of onboarding, so its presence in history is what
|
|
33
|
+
makes the "robust" fingerprint method robust.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
existing = read_project_lock(repo_root)
|
|
37
|
+
if existing:
|
|
38
|
+
return existing
|
|
39
|
+
|
|
40
|
+
lock_path = repo_root / LOCK_RELATIVE_PATH
|
|
41
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
new_lock_id = lock_id or uuid.uuid4().hex
|
|
43
|
+
lock_path.write_text(new_lock_id + "\n", encoding="utf-8")
|
|
44
|
+
return new_lock_id
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Local, on-disk mirror of registered projects.
|
|
2
|
+
|
|
3
|
+
register_project() (api_client/registry_client.py) is the source of truth
|
|
4
|
+
for licensing/quota purposes, but list_projects/project_status must not need
|
|
5
|
+
a network round trip merely to enumerate what's registered on this machine -
|
|
6
|
+
this module caches the last-known ProjectConfig per project_id, refreshed
|
|
7
|
+
every time register_project() succeeds.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from contracts.dtos import ProjectConfig
|
|
16
|
+
|
|
17
|
+
_FILENAME = "projects.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _registry_path(home_dir: Path) -> Path:
|
|
21
|
+
return home_dir / _FILENAME
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_local_projects(home_dir: Path) -> dict[str, ProjectConfig]:
|
|
25
|
+
path = _registry_path(home_dir)
|
|
26
|
+
if not path.exists():
|
|
27
|
+
return {}
|
|
28
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
29
|
+
return {project_id: ProjectConfig.model_validate(data) for project_id, data in raw.items()}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def save_local_project(home_dir: Path, project: ProjectConfig) -> None:
|
|
33
|
+
projects = load_local_projects(home_dir)
|
|
34
|
+
projects[project.project_id] = project
|
|
35
|
+
path = _registry_path(home_dir)
|
|
36
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
path.write_text(
|
|
38
|
+
json.dumps({pid: p.model_dump(mode="json") for pid, p in projects.items()}, indent=2),
|
|
39
|
+
encoding="utf-8",
|
|
40
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Parse and validate credentials.yaml into a Manifest (architecture doc S6).
|
|
2
|
+
|
|
3
|
+
Used only at mint time - `Minter` on the server, `mint()` client-side
|
|
4
|
+
below - never sent per call.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from contracts.dtos import Manifest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CredentialsError(RuntimeError):
|
|
17
|
+
"""Raised when credentials.yaml is missing or malformed."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_manifest(path: str | Path) -> Manifest:
|
|
21
|
+
"""Load and validate a credentials.yaml file into a `Manifest`."""
|
|
22
|
+
|
|
23
|
+
credentials_path = Path(path)
|
|
24
|
+
if not credentials_path.is_file():
|
|
25
|
+
raise CredentialsError(f"credentials_file_not_found: {credentials_path}")
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
raw = yaml.safe_load(credentials_path.read_text(encoding="utf-8")) or {}
|
|
29
|
+
except yaml.YAMLError as exc:
|
|
30
|
+
raise CredentialsError(f"credentials_yaml_invalid: {exc}") from exc
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
return Manifest.model_validate(raw)
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
raise CredentialsError(f"credentials_schema_invalid: {exc}") from exc
|
|
File without changes
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""MCP action progress and timeout monitoring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ToolExecutionError(RuntimeError):
|
|
13
|
+
"""Structured execution error for MCP tools."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, code: str, message: str, *, details: dict[str, Any] | None = None) -> None:
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.code = code
|
|
18
|
+
self.message = message
|
|
19
|
+
self.details = details or {}
|
|
20
|
+
|
|
21
|
+
def to_payload(self, *, tool: str, stage: str | None = None) -> dict[str, Any]:
|
|
22
|
+
payload = {
|
|
23
|
+
"error": self.code,
|
|
24
|
+
"message": self.message,
|
|
25
|
+
"details": {
|
|
26
|
+
"tool": tool,
|
|
27
|
+
**self.details,
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
if stage is not None:
|
|
31
|
+
payload["details"]["stage"] = stage
|
|
32
|
+
return payload
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ActionInactivityTimeout(ToolExecutionError):
|
|
36
|
+
"""Raised when an MCP action stops making relevant progress."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
tool: str,
|
|
42
|
+
stage: str,
|
|
43
|
+
idle_seconds: int,
|
|
44
|
+
timeout_seconds: int,
|
|
45
|
+
last_progress: dict[str, Any],
|
|
46
|
+
) -> None:
|
|
47
|
+
super().__init__(
|
|
48
|
+
"action_timed_out",
|
|
49
|
+
f"Action exceeded {timeout_seconds}s without relevant progress in stage '{stage}'.",
|
|
50
|
+
details={
|
|
51
|
+
"tool": tool,
|
|
52
|
+
"stage": stage,
|
|
53
|
+
"idle_seconds": idle_seconds,
|
|
54
|
+
"timeout_seconds": timeout_seconds,
|
|
55
|
+
"last_progress": last_progress,
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class ActionMonitor:
|
|
62
|
+
"""Emit MCP progress/log notifications and enforce inactivity timeouts."""
|
|
63
|
+
|
|
64
|
+
session: Any
|
|
65
|
+
logger: logging.Logger
|
|
66
|
+
tool: str
|
|
67
|
+
request_id: str | int
|
|
68
|
+
timeout_seconds: int
|
|
69
|
+
heartbeat_seconds: int
|
|
70
|
+
progress_token: str | int | None = None
|
|
71
|
+
project: str | None = None
|
|
72
|
+
stage_name: str = "queued"
|
|
73
|
+
progress_value: float = 0.0
|
|
74
|
+
total: float | None = None
|
|
75
|
+
started_at: float = field(default_factory=time.monotonic)
|
|
76
|
+
last_relevant_change_at: float = field(default_factory=time.monotonic)
|
|
77
|
+
last_progress: dict[str, Any] = field(default_factory=dict)
|
|
78
|
+
_closed: bool = False
|
|
79
|
+
_timeout_error: ActionInactivityTimeout | None = None
|
|
80
|
+
_heartbeat_task: asyncio.Task[None] | None = None
|
|
81
|
+
_owner_task: asyncio.Task[Any] | None = None
|
|
82
|
+
|
|
83
|
+
async def start(self, message: str, *, project: str | None = None) -> None:
|
|
84
|
+
self.project = project or self.project
|
|
85
|
+
self._owner_task = asyncio.current_task()
|
|
86
|
+
await self._emit(
|
|
87
|
+
event="started",
|
|
88
|
+
stage="started",
|
|
89
|
+
message=message,
|
|
90
|
+
relevant=True,
|
|
91
|
+
)
|
|
92
|
+
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
|
93
|
+
|
|
94
|
+
async def complete(self, message: str, *, details: dict[str, Any] | None = None) -> None:
|
|
95
|
+
await self._emit(
|
|
96
|
+
event="completed",
|
|
97
|
+
stage=self.stage_name,
|
|
98
|
+
message=message,
|
|
99
|
+
relevant=True,
|
|
100
|
+
details=details,
|
|
101
|
+
)
|
|
102
|
+
await self.close()
|
|
103
|
+
|
|
104
|
+
async def fail(self, message: str, *, stage: str | None = None, details: dict[str, Any] | None = None) -> None:
|
|
105
|
+
await self._emit(
|
|
106
|
+
event="failed",
|
|
107
|
+
stage=stage or self.stage_name,
|
|
108
|
+
message=message,
|
|
109
|
+
relevant=False,
|
|
110
|
+
details=details,
|
|
111
|
+
level="error",
|
|
112
|
+
)
|
|
113
|
+
await self.close()
|
|
114
|
+
|
|
115
|
+
async def stage(
|
|
116
|
+
self,
|
|
117
|
+
stage: str,
|
|
118
|
+
message: str,
|
|
119
|
+
*,
|
|
120
|
+
relevant: bool = True,
|
|
121
|
+
details: dict[str, Any] | None = None,
|
|
122
|
+
progress_increment: float = 1.0,
|
|
123
|
+
total: float | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
await self._emit(
|
|
126
|
+
event="progress",
|
|
127
|
+
stage=stage,
|
|
128
|
+
message=message,
|
|
129
|
+
relevant=relevant,
|
|
130
|
+
details=details,
|
|
131
|
+
progress_increment=progress_increment,
|
|
132
|
+
total=total,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
async def check_active(self) -> None:
|
|
136
|
+
if self._timeout_error is not None:
|
|
137
|
+
raise self._timeout_error
|
|
138
|
+
|
|
139
|
+
async def close(self) -> None:
|
|
140
|
+
self._closed = True
|
|
141
|
+
if self._heartbeat_task is not None:
|
|
142
|
+
self._heartbeat_task.cancel()
|
|
143
|
+
try:
|
|
144
|
+
await self._heartbeat_task
|
|
145
|
+
except asyncio.CancelledError:
|
|
146
|
+
pass
|
|
147
|
+
self._heartbeat_task = None
|
|
148
|
+
|
|
149
|
+
def timeout_error(self) -> ActionInactivityTimeout | None:
|
|
150
|
+
return self._timeout_error
|
|
151
|
+
|
|
152
|
+
async def _heartbeat_loop(self) -> None:
|
|
153
|
+
try:
|
|
154
|
+
while not self._closed:
|
|
155
|
+
await asyncio.sleep(self.heartbeat_seconds)
|
|
156
|
+
if self._closed:
|
|
157
|
+
return
|
|
158
|
+
idle_seconds = int(time.monotonic() - self.last_relevant_change_at)
|
|
159
|
+
if idle_seconds >= self.timeout_seconds:
|
|
160
|
+
self._timeout_error = ActionInactivityTimeout(
|
|
161
|
+
tool=self.tool,
|
|
162
|
+
stage=self.stage_name,
|
|
163
|
+
idle_seconds=idle_seconds,
|
|
164
|
+
timeout_seconds=self.timeout_seconds,
|
|
165
|
+
last_progress=dict(self.last_progress),
|
|
166
|
+
)
|
|
167
|
+
if self._owner_task is not None:
|
|
168
|
+
self._owner_task.cancel()
|
|
169
|
+
return
|
|
170
|
+
elapsed_seconds = int(time.monotonic() - self.started_at)
|
|
171
|
+
await self._emit(
|
|
172
|
+
event="heartbeat",
|
|
173
|
+
stage=self.stage_name,
|
|
174
|
+
message=f"{self.tool}: sigo en {self.stage_name} ({elapsed_seconds}s transcurridos, {idle_seconds}s sin avance relevante)",
|
|
175
|
+
relevant=False,
|
|
176
|
+
details={
|
|
177
|
+
"elapsed_seconds": elapsed_seconds,
|
|
178
|
+
"idle_seconds": idle_seconds,
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
except asyncio.CancelledError:
|
|
182
|
+
raise
|
|
183
|
+
|
|
184
|
+
async def _emit(
|
|
185
|
+
self,
|
|
186
|
+
*,
|
|
187
|
+
event: str,
|
|
188
|
+
stage: str,
|
|
189
|
+
message: str,
|
|
190
|
+
relevant: bool,
|
|
191
|
+
details: dict[str, Any] | None = None,
|
|
192
|
+
progress_increment: float = 1.0,
|
|
193
|
+
total: float | None = None,
|
|
194
|
+
level: str = "info",
|
|
195
|
+
) -> None:
|
|
196
|
+
self.stage_name = stage
|
|
197
|
+
merged_details = {
|
|
198
|
+
"tool": self.tool,
|
|
199
|
+
"stage": stage,
|
|
200
|
+
"event": event,
|
|
201
|
+
"project": self.project,
|
|
202
|
+
**(details or {}),
|
|
203
|
+
}
|
|
204
|
+
if relevant:
|
|
205
|
+
self.progress_value += progress_increment
|
|
206
|
+
self.last_relevant_change_at = time.monotonic()
|
|
207
|
+
self.last_progress = {
|
|
208
|
+
"stage": stage,
|
|
209
|
+
"message": message,
|
|
210
|
+
"progress": self.progress_value,
|
|
211
|
+
**merged_details,
|
|
212
|
+
}
|
|
213
|
+
if total is not None:
|
|
214
|
+
self.total = total
|
|
215
|
+
self.logger.log(
|
|
216
|
+
logging.ERROR if level == "error" else logging.INFO,
|
|
217
|
+
"mcp_action tool=%s event=%s stage=%s project=%s progress=%s details=%s message=%s",
|
|
218
|
+
self.tool,
|
|
219
|
+
event,
|
|
220
|
+
stage,
|
|
221
|
+
self.project,
|
|
222
|
+
self.progress_value,
|
|
223
|
+
merged_details,
|
|
224
|
+
message,
|
|
225
|
+
)
|
|
226
|
+
if self.session is not None:
|
|
227
|
+
try:
|
|
228
|
+
await self.session.send_log_message(
|
|
229
|
+
level=level,
|
|
230
|
+
data=message,
|
|
231
|
+
logger="codepreproc.mcp",
|
|
232
|
+
related_request_id=self.request_id,
|
|
233
|
+
)
|
|
234
|
+
except Exception:
|
|
235
|
+
self.logger.debug("failed_to_send_log_message tool=%s stage=%s", self.tool, stage, exc_info=True)
|
|
236
|
+
if self.progress_token is not None:
|
|
237
|
+
try:
|
|
238
|
+
await self.session.send_progress_notification(
|
|
239
|
+
progress_token=self.progress_token,
|
|
240
|
+
progress=self.progress_value,
|
|
241
|
+
total=self.total,
|
|
242
|
+
message=message,
|
|
243
|
+
related_request_id=self.request_id,
|
|
244
|
+
)
|
|
245
|
+
except Exception:
|
|
246
|
+
self.logger.debug("failed_to_send_progress tool=%s stage=%s", self.tool, stage, exc_info=True)
|