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.
Files changed (100) hide show
  1. codepreproc_client/__init__.py +14 -0
  2. codepreproc_client/__main__.py +9 -0
  3. codepreproc_client/layer1_business/__init__.py +0 -0
  4. codepreproc_client/layer1_business/allowed_scope_cache.py +62 -0
  5. codepreproc_client/layer1_business/api_client/__init__.py +0 -0
  6. codepreproc_client/layer1_business/api_client/lease_client.py +34 -0
  7. codepreproc_client/layer1_business/api_client/net_guard.py +98 -0
  8. codepreproc_client/layer1_business/api_client/promote_client.py +59 -0
  9. codepreproc_client/layer1_business/api_client/reasoning_client.py +163 -0
  10. codepreproc_client/layer1_business/api_client/registry_client.py +119 -0
  11. codepreproc_client/layer1_business/api_client/superkg_client.py +327 -0
  12. codepreproc_client/layer1_business/cli/__init__.py +0 -0
  13. codepreproc_client/layer1_business/cli/init.py +578 -0
  14. codepreproc_client/layer1_business/cli/main.py +78 -0
  15. codepreproc_client/layer1_business/config.py +104 -0
  16. codepreproc_client/layer1_business/git_utils.py +67 -0
  17. codepreproc_client/layer1_business/license/__init__.py +0 -0
  18. codepreproc_client/layer1_business/license/fingerprint.py +61 -0
  19. codepreproc_client/layer1_business/license/jwt_client.py +60 -0
  20. codepreproc_client/layer1_business/license/project_lock.py +44 -0
  21. codepreproc_client/layer1_business/local_registry.py +40 -0
  22. codepreproc_client/layer1_business/manifest/__init__.py +0 -0
  23. codepreproc_client/layer1_business/manifest/credentials.py +35 -0
  24. codepreproc_client/layer1_business/mcp/__init__.py +0 -0
  25. codepreproc_client/layer1_business/mcp/action_monitor.py +246 -0
  26. codepreproc_client/layer1_business/mcp/lifecycle.py +649 -0
  27. codepreproc_client/layer1_business/mcp/server.py +656 -0
  28. codepreproc_client/layer1_business/mcp/tools.py +1665 -0
  29. codepreproc_client/layer1_business/project_registry.py +99 -0
  30. codepreproc_client/layer2_tooling/__init__.py +0 -0
  31. codepreproc_client/layer2_tooling/condensation/__init__.py +0 -0
  32. codepreproc_client/layer2_tooling/condensation/ast_graph.py +426 -0
  33. codepreproc_client/layer2_tooling/condensation/bm25_store.py +132 -0
  34. codepreproc_client/layer2_tooling/condensation/chunking/__init__.py +0 -0
  35. codepreproc_client/layer2_tooling/condensation/chunking/ast_chunker.py +313 -0
  36. codepreproc_client/layer2_tooling/condensation/chunking/languages.py +238 -0
  37. codepreproc_client/layer2_tooling/condensation/embed_worker.py +128 -0
  38. codepreproc_client/layer2_tooling/condensation/embeddings.py +452 -0
  39. codepreproc_client/layer2_tooling/condensation/extract/__init__.py +0 -0
  40. codepreproc_client/layer2_tooling/condensation/extract/compactor.py +103 -0
  41. codepreproc_client/layer2_tooling/condensation/extract/providers/__init__.py +0 -0
  42. codepreproc_client/layer2_tooling/condensation/extract/providers/base.py +38 -0
  43. codepreproc_client/layer2_tooling/condensation/indexer.py +945 -0
  44. codepreproc_client/layer2_tooling/condensation/ppl/__init__.py +0 -0
  45. codepreproc_client/layer2_tooling/condensation/ppl/emitter.py +162 -0
  46. codepreproc_client/layer2_tooling/condensation/ppl/parser.py +352 -0
  47. codepreproc_client/layer2_tooling/condensation/qdrant_store.py +387 -0
  48. codepreproc_client/layer2_tooling/condensation/scanning/__init__.py +0 -0
  49. codepreproc_client/layer2_tooling/condensation/scanning/manifest_scanner.py +72 -0
  50. codepreproc_client/layer2_tooling/condensation/scanning/md_scanner.py +97 -0
  51. codepreproc_client/layer2_tooling/condensation/scanning/repo_mapper.py +495 -0
  52. codepreproc_client/layer2_tooling/condensation/session_vector_store.py +69 -0
  53. codepreproc_client/layer2_tooling/coupling/__init__.py +0 -0
  54. codepreproc_client/layer2_tooling/coupling/domain.py +263 -0
  55. codepreproc_client/layer2_tooling/coupling/graph.py +255 -0
  56. codepreproc_client/layer2_tooling/coupling/layer2_gate.py +189 -0
  57. codepreproc_client/layer2_tooling/coupling/projector.py +139 -0
  58. codepreproc_client/layer2_tooling/coupling/resolver.py +150 -0
  59. codepreproc_client/layer2_tooling/materialization/__init__.py +0 -0
  60. codepreproc_client/layer2_tooling/materialization/pipeline/__init__.py +0 -0
  61. codepreproc_client/layer2_tooling/materialization/pipeline/execute_semantic.py +1249 -0
  62. codepreproc_client/layer2_tooling/materialization/pipeline/filesystem_reorg.py +575 -0
  63. codepreproc_client/layer2_tooling/materialization/pipeline/patch_generator.py +127 -0
  64. codepreproc_client/layer2_tooling/materialization/pipeline/patch_validator.py +101 -0
  65. codepreproc_client/layer2_tooling/materialization/semantic/__init__.py +19 -0
  66. codepreproc_client/layer2_tooling/materialization/semantic/anchors.py +61 -0
  67. codepreproc_client/layer2_tooling/materialization/semantic/applicability.py +75 -0
  68. codepreproc_client/layer2_tooling/materialization/semantic/edit_planner.py +264 -0
  69. codepreproc_client/layer2_tooling/materialization/semantic/materializer.py +121 -0
  70. codepreproc_client/layer2_tooling/materialization/semantic/merger.py +73 -0
  71. codepreproc_client/layer2_tooling/materialization/semantic/ops/__init__.py +0 -0
  72. codepreproc_client/layer2_tooling/materialization/semantic/ops/add_import.py +36 -0
  73. codepreproc_client/layer2_tooling/materialization/semantic/ops/append_argument.py +37 -0
  74. codepreproc_client/layer2_tooling/materialization/semantic/ops/insert_literal.py +24 -0
  75. codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_import.py +33 -0
  76. codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_statement_unique.py +14 -0
  77. codepreproc_client/layer2_tooling/materialization/semantic/ops/rename_symbol_local.py +16 -0
  78. codepreproc_client/layer2_tooling/materialization/semantic/region_resolver.py +229 -0
  79. codepreproc_client/layer2_tooling/materialization/semantic/synthesizer.py +186 -0
  80. codepreproc_client/layer2_tooling/materialization/semantic/target_locator.py +94 -0
  81. codepreproc_client/layer2_tooling/materialization/semantic/validator.py +322 -0
  82. codepreproc_client/layer2_tooling/materialization/snippet/__init__.py +0 -0
  83. codepreproc_client/layer2_tooling/materialization/snippet/assembler.py +503 -0
  84. codepreproc_client/layer2_tooling/materialization/snippet/loader.py +187 -0
  85. codepreproc_client/layer2_tooling/retrieval/__init__.py +0 -0
  86. codepreproc_client/layer2_tooling/retrieval/graph_walk.py +47 -0
  87. codepreproc_client/layer2_tooling/retrieval/hybrid.py +95 -0
  88. codepreproc_client/layer2_tooling/retrieval/rerank_worker.py +86 -0
  89. codepreproc_client/layer2_tooling/retrieval/reranker.py +213 -0
  90. codepreproc_client/layer2_tooling/watcher/__init__.py +0 -0
  91. codepreproc_client/layer2_tooling/watcher/fs_watcher.py +3 -0
  92. codrspot_processor_mcp-0.1.0.dist-info/METADATA +396 -0
  93. codrspot_processor_mcp-0.1.0.dist-info/RECORD +100 -0
  94. codrspot_processor_mcp-0.1.0.dist-info/WHEEL +5 -0
  95. codrspot_processor_mcp-0.1.0.dist-info/entry_points.txt +3 -0
  96. codrspot_processor_mcp-0.1.0.dist-info/licenses/LICENSE +23 -0
  97. codrspot_processor_mcp-0.1.0.dist-info/top_level.txt +2 -0
  98. contracts/__init__.py +0 -0
  99. contracts/dtos.py +1239 -0
  100. 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
+ )
@@ -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)