ctxora 6.2.0

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 (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +441 -0
  3. package/README.vi.md +441 -0
  4. package/bin/ctxora.mjs +147 -0
  5. package/package.json +45 -0
  6. package/pyproject.toml +59 -0
  7. package/src/chunking/compressor.py +104 -0
  8. package/src/chunking/treesitter_chunker.py +240 -0
  9. package/src/compact/anthropic.py +98 -0
  10. package/src/compact/gemini.py +88 -0
  11. package/src/compact/handoff.py +179 -0
  12. package/src/compact/openai.py +318 -0
  13. package/src/compact/summarizer.py +186 -0
  14. package/src/context/assembler.py +298 -0
  15. package/src/context/budgeting.py +137 -0
  16. package/src/context/sanitizer.py +23 -0
  17. package/src/evaluation/__init__.py +1 -0
  18. package/src/evaluation/gates.py +172 -0
  19. package/src/evaluation/metrics.py +41 -0
  20. package/src/harness_context/__init__.py +5 -0
  21. package/src/harness_context/adapters/__init__.py +1 -0
  22. package/src/harness_context/adapters/clients/__init__.py +4 -0
  23. package/src/harness_context/adapters/clients/formatters.py +47 -0
  24. package/src/harness_context/adapters/clients/profiles.py +29 -0
  25. package/src/harness_context/adapters/ecc/__init__.py +4 -0
  26. package/src/harness_context/adapters/ecc/detection.py +41 -0
  27. package/src/harness_context/adapters/ecc/mapping.py +32 -0
  28. package/src/harness_context/adapters/ecc/memory_reader.py +162 -0
  29. package/src/harness_context/adapters/ecc/provenance.py +16 -0
  30. package/src/harness_context/api/__init__.py +1 -0
  31. package/src/harness_context/api/v2/__init__.py +12 -0
  32. package/src/harness_context/api/v2/contracts.py +119 -0
  33. package/src/harness_context/api/v2/diagnostics.py +13 -0
  34. package/src/harness_context/api/v2/enums.py +17 -0
  35. package/src/harness_context/api/v2/errors.py +32 -0
  36. package/src/harness_context/api/v2/models.py +4 -0
  37. package/src/harness_context/api/v2/requests.py +17 -0
  38. package/src/harness_context/api/v2/responses.py +22 -0
  39. package/src/harness_context/application/__init__.py +3 -0
  40. package/src/harness_context/application/container.py +31 -0
  41. package/src/harness_context/application/context_service.py +51 -0
  42. package/src/harness_context/application/ecc_service.py +7 -0
  43. package/src/harness_context/application/handoff_service.py +11 -0
  44. package/src/harness_context/application/memory_service.py +9 -0
  45. package/src/harness_context/application/protocols.py +46 -0
  46. package/src/harness_context/application/refresh_service.py +25 -0
  47. package/src/harness_context/application/retrieval_service.py +22 -0
  48. package/src/harness_context/application/services.py +4 -0
  49. package/src/harness_context/application/workspace_service.py +18 -0
  50. package/src/harness_context/bootstrap.py +47 -0
  51. package/src/harness_context/branding.py +16 -0
  52. package/src/harness_context/cli/__init__.py +1 -0
  53. package/src/harness_context/cli/app.py +239 -0
  54. package/src/harness_context/cli/exit_codes.py +25 -0
  55. package/src/harness_context/domain/__init__.py +9 -0
  56. package/src/harness_context/domain/cag.py +18 -0
  57. package/src/harness_context/domain/chunking.py +17 -0
  58. package/src/harness_context/domain/planning.py +30 -0
  59. package/src/harness_context/domain/ports.py +24 -0
  60. package/src/harness_context/domain/retrieval.py +46 -0
  61. package/src/harness_context/engine.py +10 -0
  62. package/src/harness_context/free_tools.py +143 -0
  63. package/src/harness_context/infrastructure/__init__.py +10 -0
  64. package/src/harness_context/infrastructure/graph.py +26 -0
  65. package/src/harness_context/infrastructure/indexes.py +33 -0
  66. package/src/harness_context/infrastructure/local_engine.py +296 -0
  67. package/src/harness_context/infrastructure/parsing.py +38 -0
  68. package/src/harness_context/infrastructure/scanning.py +51 -0
  69. package/src/harness_context/installer/__init__.py +4 -0
  70. package/src/harness_context/installer/models.py +22 -0
  71. package/src/harness_context/installer/service.py +168 -0
  72. package/src/harness_context/mcp/__init__.py +3 -0
  73. package/src/harness_context/mcp/capabilities.py +11 -0
  74. package/src/harness_context/mcp/errors.py +8 -0
  75. package/src/harness_context/mcp/lifecycle.py +72 -0
  76. package/src/harness_context/mcp/middleware.py +57 -0
  77. package/src/harness_context/mcp/server.py +3 -0
  78. package/src/harness_context/mcp/tool_handlers/__init__.py +7 -0
  79. package/src/harness_context/mcp/tool_handlers/context.py +16 -0
  80. package/src/harness_context/mcp/tool_handlers/ecc.py +8 -0
  81. package/src/harness_context/mcp/tool_handlers/handoffs.py +20 -0
  82. package/src/harness_context/mcp/tool_handlers/memory.py +16 -0
  83. package/src/harness_context/mcp/tool_handlers/workspace.py +12 -0
  84. package/src/harness_context/mcp/tools.py +15 -0
  85. package/src/harness_context/observability/__init__.py +6 -0
  86. package/src/harness_context/observability/events.py +25 -0
  87. package/src/harness_context/observability/metrics.py +20 -0
  88. package/src/harness_context/paths.py +35 -0
  89. package/src/harness_context/runtime.py +127 -0
  90. package/src/harness_context/schemas.py +38 -0
  91. package/src/harness_context/security/__init__.py +3 -0
  92. package/src/harness_context/security/secret_patterns.py +15 -0
  93. package/src/harness_context/server.py +1077 -0
  94. package/src/harness_context/storage/__init__.py +6 -0
  95. package/src/harness_context/storage/migrations.py +24 -0
  96. package/src/harness_context/storage/pins.py +10 -0
  97. package/src/harness_context/storage/snapshots.py +149 -0
  98. package/src/harness_context/tokenize.py +12 -0
  99. package/src/harness_context/topology.py +65 -0
  100. package/src/harness_context/watcher/__init__.py +3 -0
  101. package/src/harness_context/watcher/service.py +32 -0
  102. package/src/harness_context/workspace/__init__.py +13 -0
  103. package/src/harness_context/workspace/identity.py +9 -0
  104. package/src/harness_context/workspace/lock.py +24 -0
  105. package/src/harness_context/workspace/policy.py +3 -0
  106. package/src/harness_context/workspace/roots.py +84 -0
  107. package/src/harness_context/workspace/state.py +35 -0
  108. package/src/memory/episodic.py +257 -0
  109. package/src/memory/vector_store.py +104 -0
  110. package/src/retrieval/bm25.py +23 -0
  111. package/src/retrieval/cache.py +76 -0
  112. package/src/retrieval/embeddings.py +75 -0
  113. package/src/retrieval/graph.py +45 -0
  114. package/src/retrieval/reranker.py +78 -0
  115. package/src/retrieval/tokenize.py +11 -0
@@ -0,0 +1,6 @@
1
+ """Local persistence adapters."""
2
+
3
+ from harness_context.storage.pins import SnapshotPin
4
+ from harness_context.storage.snapshots import SnapshotStore
5
+
6
+ __all__ = ["SnapshotPin", "SnapshotStore"]
@@ -0,0 +1,24 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class Migration:
7
+ version: int
8
+ apply: object
9
+ rollback: object | None = None
10
+
11
+
12
+ def _version_one(state_dir: Path) -> None:
13
+ (state_dir / "snapshots").mkdir(parents=True, exist_ok=True)
14
+
15
+
16
+ def _version_two(state_dir: Path) -> None:
17
+ (state_dir / "candidates").mkdir(parents=True, exist_ok=True)
18
+
19
+
20
+ def _rollback_version_two(state_dir: Path) -> None:
21
+ (state_dir / "candidates").rmdir()
22
+
23
+
24
+ MIGRATIONS = (Migration(1, _version_one), Migration(2, _version_two, _rollback_version_two))
@@ -0,0 +1,10 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class SnapshotPin:
7
+ workspace_id: str
8
+ snapshot_id: str
9
+ snapshot_version: int
10
+ state: Any
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import time
7
+ import uuid
8
+ from pathlib import Path
9
+
10
+ from harness_context.paths import workspace_state_dir
11
+ from harness_context.storage.migrations import MIGRATIONS
12
+ from harness_context.workspace import file_lock, validate_ready_snapshot
13
+
14
+ SCHEMA_VERSION = 2
15
+
16
+
17
+ class SnapshotStore:
18
+ def __init__(self, workspace_root: str | Path):
19
+ self.state_dir = workspace_state_dir(workspace_root)
20
+ self.snapshots_dir = self.state_dir / "snapshots"
21
+ self.candidates_dir = self.state_dir / "candidates"
22
+ self.active_path = self.state_dir / "active.json"
23
+ self.previous_path = self.state_dir / "active.previous.json"
24
+ self.schema_path = self.state_dir / "schema.json"
25
+ self.lock_path = self.state_dir / "state.lock"
26
+
27
+ @staticmethod
28
+ def _atomic_json(path: Path, payload: dict) -> None:
29
+ path.parent.mkdir(parents=True, exist_ok=True)
30
+ descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
31
+ try:
32
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
33
+ json.dump(payload, handle, ensure_ascii=False, sort_keys=True)
34
+ handle.flush()
35
+ os.fsync(handle.fileno())
36
+ os.replace(temporary, path)
37
+ finally:
38
+ if os.path.exists(temporary):
39
+ os.unlink(temporary)
40
+
41
+ def prepare(self) -> None:
42
+ self.state_dir.mkdir(parents=True, exist_ok=True)
43
+ with file_lock(self.lock_path):
44
+ version = json.loads(self.schema_path.read_text("utf-8")).get("version", 0) if self.schema_path.exists() else 0
45
+ if version > SCHEMA_VERSION:
46
+ raise RuntimeError(f"snapshot schema {version} is newer than supported {SCHEMA_VERSION}")
47
+ for migration in MIGRATIONS:
48
+ if migration.version > version:
49
+ try:
50
+ migration.apply(self.state_dir)
51
+ except Exception:
52
+ if migration.rollback is not None:
53
+ migration.rollback(self.state_dir)
54
+ raise
55
+ self._atomic_json(self.schema_path, {"version": migration.version})
56
+ version = migration.version
57
+ for temporary in self.state_dir.rglob("*.tmp"):
58
+ temporary.unlink(missing_ok=True)
59
+ self._recover_active()
60
+
61
+ def _recover_active(self) -> None:
62
+ if not self.active_path.exists() and self.previous_path.exists():
63
+ os.replace(self.previous_path, self.active_path)
64
+ if self.active_path.exists():
65
+ try:
66
+ active = json.loads(self.active_path.read_text("utf-8"))
67
+ except (json.JSONDecodeError, OSError):
68
+ active = {}
69
+ if not (self.snapshots_dir / f"{active.get('snapshot_id', '')}.json").exists() and self.previous_path.exists():
70
+ os.replace(self.previous_path, self.active_path)
71
+
72
+ def create_candidate(self, snapshot: dict) -> str:
73
+ validate_ready_snapshot(snapshot)
74
+ candidate_id = self.begin_candidate(snapshot["workspace_id"])
75
+ self.validate_candidate(candidate_id, snapshot)
76
+ return candidate_id
77
+
78
+ def begin_candidate(self, workspace_id: str) -> str:
79
+ self.prepare()
80
+ candidate_id = uuid.uuid4().hex
81
+ self._atomic_json(self.candidates_dir / f"{candidate_id}.json", {
82
+ "candidate_id": candidate_id, "workspace_id": workspace_id,
83
+ "status": "building", "created_at": time.time(),
84
+ })
85
+ return candidate_id
86
+
87
+ def validate_candidate(self, candidate_id: str, snapshot: dict) -> None:
88
+ validate_ready_snapshot(snapshot)
89
+ record_path = self.candidates_dir / f"{candidate_id}.json"
90
+ record = json.loads(record_path.read_text("utf-8"))
91
+ if record["workspace_id"] != snapshot["workspace_id"]:
92
+ raise ValueError("candidate workspace does not match snapshot")
93
+ record.update(status="validated", snapshot=snapshot, validated_at=time.time())
94
+ self._atomic_json(record_path, record)
95
+
96
+ def fail_candidate(self, candidate_id: str, error: Exception) -> None:
97
+ record_path = self.candidates_dir / f"{candidate_id}.json"
98
+ if not record_path.exists():
99
+ return
100
+ record = json.loads(record_path.read_text("utf-8"))
101
+ record.update(status="failed", failed_at=time.time(), error=str(error))
102
+ self._atomic_json(record_path, record)
103
+
104
+ def promote_candidate(self, candidate_id: str) -> None:
105
+ with file_lock(self.lock_path):
106
+ record_path = self.candidates_dir / f"{candidate_id}.json"
107
+ record = json.loads(record_path.read_text("utf-8"))
108
+ if record.get("status") != "validated":
109
+ raise ValueError("only validated candidates may be promoted")
110
+ snapshot = record["snapshot"]
111
+ validate_ready_snapshot(snapshot)
112
+ snapshot_path = self.snapshots_dir / f"{snapshot['snapshot_id']}.json"
113
+ if not snapshot_path.exists():
114
+ self._atomic_json(snapshot_path, snapshot)
115
+ if self.active_path.exists():
116
+ self._atomic_json(self.previous_path, json.loads(self.active_path.read_text("utf-8")))
117
+ self._atomic_json(self.active_path, {
118
+ "workspace_id": snapshot["workspace_id"], "snapshot_id": snapshot["snapshot_id"],
119
+ "snapshot_version": snapshot["snapshot_version"],
120
+ })
121
+ record["status"] = "promoted"
122
+ self._atomic_json(record_path, record)
123
+ retained = sorted(self.snapshots_dir.glob("*.json"), key=lambda item: item.stat().st_mtime, reverse=True)
124
+ for expired in retained[3:]:
125
+ expired.unlink(missing_ok=True)
126
+
127
+ def promote(self, snapshot: dict) -> None:
128
+ self.promote_candidate(self.create_candidate(snapshot))
129
+
130
+ def rollback(self) -> None:
131
+ with file_lock(self.lock_path):
132
+ if not self.previous_path.exists():
133
+ raise RuntimeError("no previous active snapshot")
134
+ current = json.loads(self.active_path.read_text("utf-8")) if self.active_path.exists() else None
135
+ previous = json.loads(self.previous_path.read_text("utf-8"))
136
+ self._atomic_json(self.active_path, previous)
137
+ if current:
138
+ self._atomic_json(self.previous_path, current)
139
+
140
+ def load_active(self, workspace_id: str) -> dict | None:
141
+ self.prepare()
142
+ with file_lock(self.lock_path):
143
+ if not self.active_path.exists():
144
+ return None
145
+ active = json.loads(self.active_path.read_text("utf-8"))
146
+ if active.get("workspace_id") != workspace_id:
147
+ return None
148
+ path = self.snapshots_dir / f"{active['snapshot_id']}.json"
149
+ return json.loads(path.read_text("utf-8")) if path.exists() else None
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import unicodedata
5
+
6
+
7
+ def tokens(text: str) -> list[str]:
8
+ normalized = unicodedata.normalize("NFC", text)
9
+ normalized = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", normalized).replace("_", " ")
10
+ words = re.findall(r"[^\W_]+", normalized.casefold(), re.UNICODE)
11
+ grams = [word[index:index + 3] for word in words for index in range(max(0, len(word) - 2))]
12
+ return words + grams
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import json
5
+ from pathlib import Path
6
+
7
+ try:
8
+ import tomllib
9
+ except ModuleNotFoundError: # Python 3.10
10
+ import tomli as tomllib
11
+
12
+ REQUIRED_PATHS = (
13
+ "CHANGELOG.md", "SUPPORT.md", "docs/ARCHITECTURE.md", "docs/OPERATIONS.md",
14
+ "docs/PRICING.md",
15
+ "examples/basic_usage.py", "examples/mcp-config.json", "migrations/manifest.json",
16
+ "migrations/README.md", "schemas/README.md", "scripts/install.sh",
17
+ "scripts/migrate_state.py", "scripts/uninstall.sh", "src/harness_context/server.py",
18
+ )
19
+ LEGACY_IMPLEMENTATION_DIRS = ("harness_context", "chunking", "compact", "context", "memory", "retrieval", "evaluation")
20
+ PAID_DEPENDENCIES = {
21
+ "stripe", "launchdarkly", "ctxora_cloud", "ctxora_control_plane",
22
+ "harness_cloud", "harness_control_plane",
23
+ }
24
+
25
+
26
+ def audit(root: Path) -> list[str]:
27
+ failures: list[str] = []
28
+ for relative in REQUIRED_PATHS:
29
+ if not (root / relative).exists():
30
+ failures.append(f"missing required artifact: {relative}")
31
+ for directory in LEGACY_IMPLEMENTATION_DIRS:
32
+ if (root / directory).is_dir():
33
+ failures.append(f"legacy production package remains at repository root: {directory}/")
34
+ config = tomllib.loads((root / "pyproject.toml").read_text("utf-8"))
35
+ package_dir = config.get("tool", {}).get("setuptools", {}).get("package-dir", {})
36
+ if package_dir.get("") != "src":
37
+ failures.append("setuptools package-dir must map the package root to src")
38
+ dependencies = {item.split("[", 1)[0].split("<", 1)[0].split(">", 1)[0].split("=", 1)[0].strip().lower() for item in config["project"].get("dependencies", [])}
39
+ for dependency in sorted(dependencies & PAID_DEPENDENCIES):
40
+ failures.append(f"paid control-plane dependency is forbidden: {dependency}")
41
+ for source in sorted((root / "src").rglob("*.py")):
42
+ tree = ast.parse(source.read_text("utf-8"), filename=str(source))
43
+ imported = set()
44
+ for node in ast.walk(tree):
45
+ if isinstance(node, ast.Import):
46
+ imported.update(alias.name.split(".", 1)[0] for alias in node.names)
47
+ elif isinstance(node, ast.ImportFrom) and node.module:
48
+ imported.add(node.module.split(".", 1)[0])
49
+ for dependency in sorted(imported & PAID_DEPENDENCIES):
50
+ failures.append(f"paid control-plane import in {source.relative_to(root)}: {dependency}")
51
+ shim = (root / "server.py").read_text("utf-8")
52
+ if shim.count("\n") > 20 or "harness_context import server" not in shim:
53
+ failures.append("server.py must remain a tiny compatibility shim")
54
+ return failures
55
+
56
+
57
+ def main() -> int:
58
+ root = Path(__file__).resolve().parents[2]
59
+ failures = audit(root)
60
+ print(json.dumps({"status": "failed" if failures else "ok", "failures": failures}, sort_keys=True))
61
+ return 1 if failures else 0
62
+
63
+
64
+ if __name__ == "__main__":
65
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ from harness_context.watcher.service import WorkspaceWatcher
2
+
3
+ __all__ = ["WorkspaceWatcher"]
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+
5
+ from harness_context.application.protocols import RefreshCommands, RetrievalQueries
6
+
7
+
8
+ class WorkspaceWatcher:
9
+ """Treat filesystem events as hints and confirm freshness by content hash."""
10
+
11
+ def __init__(self, retrieval: RetrievalQueries, refresh: RefreshCommands, workspace_id: str, interval: float = 1.0):
12
+ self.retrieval = retrieval
13
+ self.refresh = refresh
14
+ self.workspace_id = workspace_id
15
+ self.interval = interval
16
+ self._stop = threading.Event()
17
+ self._thread = threading.Thread(target=self._run, name="harness-watcher", daemon=True)
18
+
19
+ def start(self) -> None:
20
+ self._thread.start()
21
+
22
+ def stop(self) -> None:
23
+ self._stop.set()
24
+ self._thread.join(timeout=max(2.0, self.interval * 2))
25
+
26
+ def _run(self) -> None:
27
+ while not self._stop.wait(self.interval):
28
+ if not self.retrieval.snapshot_is_current(self.workspace_id):
29
+ try:
30
+ self.refresh.execute(self.workspace_id)
31
+ except Exception:
32
+ continue
@@ -0,0 +1,13 @@
1
+ from harness_context.workspace.identity import workspace_identity
2
+ from harness_context.workspace.lock import file_lock
3
+ from harness_context.workspace.policy import WorkspacePolicy
4
+ from harness_context.workspace.roots import DEFAULT_DENY, WorkspaceRegistry
5
+ from harness_context.workspace.state import (
6
+ WorkspaceStatus,
7
+ transition_workspace_status,
8
+ validate_ready_snapshot,
9
+ )
10
+
11
+ _DEFAULT_DENY = DEFAULT_DENY
12
+
13
+ __all__ = ["WorkspacePolicy", "WorkspaceRegistry", "WorkspaceStatus", "file_lock", "transition_workspace_status", "validate_ready_snapshot", "workspace_identity"]
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+
6
+
7
+ def workspace_identity(root: str | Path) -> str:
8
+ canonical = str(Path(root).expanduser().resolve(strict=True))
9
+ return "ws_" + hashlib.sha256(canonical.encode()).hexdigest()[:16]
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from pathlib import Path
5
+
6
+
7
+ @contextmanager
8
+ def file_lock(path: str | Path):
9
+ lock_path = Path(path)
10
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
11
+ with lock_path.open("a+") as handle:
12
+ try:
13
+ import fcntl
14
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
15
+ except ImportError:
16
+ pass
17
+ try:
18
+ yield
19
+ finally:
20
+ try:
21
+ import fcntl
22
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
23
+ except ImportError:
24
+ pass
@@ -0,0 +1,3 @@
1
+ from harness_context.schemas import WorkspacePolicy
2
+
3
+ __all__ = ["WorkspacePolicy"]
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ from pathlib import Path
5
+
6
+ from harness_context.schemas import HarnessError
7
+ from harness_context.security import contains_secret
8
+ from harness_context.workspace.policy import WorkspacePolicy
9
+
10
+ DEFAULT_DENY = (
11
+ ".git", ".hg", ".svn", ".ctxora", ".harness", ".ecc", "node_modules", "vendor", ".venv", "venv",
12
+ "dist", "build", "__pycache__", ".env", ".env.*", "*.pem", "*.key", "id_rsa", "id_ed25519",
13
+ "*.sqlite", "*.sqlite3", "*.db", "*.egg-info",
14
+ )
15
+
16
+
17
+ class WorkspaceRegistry:
18
+ def __init__(self) -> None:
19
+ self._policies: dict[str, WorkspacePolicy] = {}
20
+
21
+ def register(self, workspace_id: str, roots: list[str], **limits: int) -> WorkspacePolicy:
22
+ if not workspace_id.strip():
23
+ raise HarnessError("invalid_workspace", "workspace_id is required")
24
+ canonical = tuple(sorted({str(Path(root).expanduser().resolve(strict=True)) for root in roots}))
25
+ if not canonical:
26
+ raise HarnessError("invalid_workspace", "at least one existing root is required")
27
+ policy = WorkspacePolicy(roots=canonical, **limits)
28
+ self._policies[workspace_id] = policy
29
+ return policy
30
+
31
+ def get(self, workspace_id: str) -> WorkspacePolicy:
32
+ try:
33
+ return self._policies[workspace_id]
34
+ except KeyError as error:
35
+ raise HarnessError("unknown_workspace", f"workspace not registered: {workspace_id}") from error
36
+
37
+ def authorize(self, workspace_id: str, paths: list[str]) -> list[Path]:
38
+ policy = self.get(workspace_id)
39
+ authorized = []
40
+ for raw in paths:
41
+ unresolved = Path(raw).expanduser()
42
+ if unresolved.is_symlink():
43
+ raise HarnessError("symlink_path", f"symlink paths are not allowed: {unresolved.name}")
44
+ path = unresolved.resolve(strict=False)
45
+ if not any(path == Path(root) or Path(root) in path.parents for root in policy.roots):
46
+ raise HarnessError("unauthorized_path", f"path is outside workspace roots: {path.name}")
47
+ authorized.append(path)
48
+ return authorized
49
+
50
+ @staticmethod
51
+ def _patterns(root: Path) -> list[str]:
52
+ patterns = list(DEFAULT_DENY)
53
+ for name in (".gitignore", ".ctxoraignore", ".harnessignore"):
54
+ ignore = root / name
55
+ if ignore.is_file():
56
+ patterns.extend(line.strip().lstrip("/") for line in ignore.read_text("utf-8").splitlines() if line.strip() and not line.lstrip().startswith("#") and not line.startswith("!"))
57
+ return patterns
58
+
59
+ def files(self, workspace_id: str, paths: list[str]) -> list[Path]:
60
+ policy = self.get(workspace_id)
61
+ selected, total = [], 0
62
+ for source in self.authorize(workspace_id, paths):
63
+ if not source.exists():
64
+ continue
65
+ base = source if source.is_dir() else source.parent
66
+ patterns = self._patterns(base)
67
+ candidates = [source] if source.is_file() else source.rglob("*")
68
+ for path in candidates:
69
+ if not path.is_file() or path.is_symlink():
70
+ continue
71
+ relative = path.relative_to(base).as_posix()
72
+ if any(fnmatch.fnmatch(relative, pattern) or any(fnmatch.fnmatch(part, pattern) for part in path.parts) for pattern in patterns):
73
+ continue
74
+ size = path.stat().st_size
75
+ if size > policy.max_file_bytes:
76
+ continue
77
+ content = path.read_bytes()
78
+ if b"\x00" in content[:4096] or contains_secret(content):
79
+ continue
80
+ total += size
81
+ if total > policy.max_total_bytes or len(selected) >= policy.max_files:
82
+ raise HarnessError("workspace_limit", "workspace indexing limits exceeded")
83
+ selected.append(path)
84
+ return sorted(set(selected))
@@ -0,0 +1,35 @@
1
+ from enum import Enum
2
+
3
+
4
+ class WorkspaceStatus(str, Enum):
5
+ REGISTERED = "registered"
6
+ INDEXING = "indexing"
7
+ READY = "ready"
8
+
9
+
10
+ _TRANSITIONS = {
11
+ WorkspaceStatus.REGISTERED: {WorkspaceStatus.INDEXING},
12
+ WorkspaceStatus.INDEXING: {WorkspaceStatus.READY},
13
+ WorkspaceStatus.READY: {WorkspaceStatus.INDEXING, WorkspaceStatus.REGISTERED},
14
+ }
15
+
16
+
17
+ def transition_workspace_status(current: str | WorkspaceStatus, target: str | WorkspaceStatus) -> WorkspaceStatus:
18
+ current_status = WorkspaceStatus(current)
19
+ target_status = WorkspaceStatus(target)
20
+ if target_status not in _TRANSITIONS[current_status]:
21
+ raise ValueError(f"invalid workspace transition: {current_status.value} -> {target_status.value}")
22
+ return target_status
23
+
24
+
25
+ def validate_ready_snapshot(snapshot: dict) -> None:
26
+ required = {"workspace_id", "snapshot_id", "snapshot_version", "status", "fingerprints", "items"}
27
+ missing = required - snapshot.keys()
28
+ if missing:
29
+ raise ValueError(f"snapshot is missing required fields: {sorted(missing)}")
30
+ if snapshot["status"] != WorkspaceStatus.READY:
31
+ raise ValueError("only ready snapshots may be promoted")
32
+ if not snapshot["snapshot_id"] or int(snapshot["snapshot_version"]) < 1:
33
+ raise ValueError("snapshot identity is invalid")
34
+ if any(item.get("workspace_id") != snapshot["workspace_id"] for item in snapshot["items"]):
35
+ raise ValueError("snapshot contains items from another workspace")