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.
- package/LICENSE +21 -0
- package/README.md +441 -0
- package/README.vi.md +441 -0
- package/bin/ctxora.mjs +147 -0
- package/package.json +45 -0
- package/pyproject.toml +59 -0
- package/src/chunking/compressor.py +104 -0
- package/src/chunking/treesitter_chunker.py +240 -0
- package/src/compact/anthropic.py +98 -0
- package/src/compact/gemini.py +88 -0
- package/src/compact/handoff.py +179 -0
- package/src/compact/openai.py +318 -0
- package/src/compact/summarizer.py +186 -0
- package/src/context/assembler.py +298 -0
- package/src/context/budgeting.py +137 -0
- package/src/context/sanitizer.py +23 -0
- package/src/evaluation/__init__.py +1 -0
- package/src/evaluation/gates.py +172 -0
- package/src/evaluation/metrics.py +41 -0
- package/src/harness_context/__init__.py +5 -0
- package/src/harness_context/adapters/__init__.py +1 -0
- package/src/harness_context/adapters/clients/__init__.py +4 -0
- package/src/harness_context/adapters/clients/formatters.py +47 -0
- package/src/harness_context/adapters/clients/profiles.py +29 -0
- package/src/harness_context/adapters/ecc/__init__.py +4 -0
- package/src/harness_context/adapters/ecc/detection.py +41 -0
- package/src/harness_context/adapters/ecc/mapping.py +32 -0
- package/src/harness_context/adapters/ecc/memory_reader.py +162 -0
- package/src/harness_context/adapters/ecc/provenance.py +16 -0
- package/src/harness_context/api/__init__.py +1 -0
- package/src/harness_context/api/v2/__init__.py +12 -0
- package/src/harness_context/api/v2/contracts.py +119 -0
- package/src/harness_context/api/v2/diagnostics.py +13 -0
- package/src/harness_context/api/v2/enums.py +17 -0
- package/src/harness_context/api/v2/errors.py +32 -0
- package/src/harness_context/api/v2/models.py +4 -0
- package/src/harness_context/api/v2/requests.py +17 -0
- package/src/harness_context/api/v2/responses.py +22 -0
- package/src/harness_context/application/__init__.py +3 -0
- package/src/harness_context/application/container.py +31 -0
- package/src/harness_context/application/context_service.py +51 -0
- package/src/harness_context/application/ecc_service.py +7 -0
- package/src/harness_context/application/handoff_service.py +11 -0
- package/src/harness_context/application/memory_service.py +9 -0
- package/src/harness_context/application/protocols.py +46 -0
- package/src/harness_context/application/refresh_service.py +25 -0
- package/src/harness_context/application/retrieval_service.py +22 -0
- package/src/harness_context/application/services.py +4 -0
- package/src/harness_context/application/workspace_service.py +18 -0
- package/src/harness_context/bootstrap.py +47 -0
- package/src/harness_context/branding.py +16 -0
- package/src/harness_context/cli/__init__.py +1 -0
- package/src/harness_context/cli/app.py +239 -0
- package/src/harness_context/cli/exit_codes.py +25 -0
- package/src/harness_context/domain/__init__.py +9 -0
- package/src/harness_context/domain/cag.py +18 -0
- package/src/harness_context/domain/chunking.py +17 -0
- package/src/harness_context/domain/planning.py +30 -0
- package/src/harness_context/domain/ports.py +24 -0
- package/src/harness_context/domain/retrieval.py +46 -0
- package/src/harness_context/engine.py +10 -0
- package/src/harness_context/free_tools.py +143 -0
- package/src/harness_context/infrastructure/__init__.py +10 -0
- package/src/harness_context/infrastructure/graph.py +26 -0
- package/src/harness_context/infrastructure/indexes.py +33 -0
- package/src/harness_context/infrastructure/local_engine.py +296 -0
- package/src/harness_context/infrastructure/parsing.py +38 -0
- package/src/harness_context/infrastructure/scanning.py +51 -0
- package/src/harness_context/installer/__init__.py +4 -0
- package/src/harness_context/installer/models.py +22 -0
- package/src/harness_context/installer/service.py +168 -0
- package/src/harness_context/mcp/__init__.py +3 -0
- package/src/harness_context/mcp/capabilities.py +11 -0
- package/src/harness_context/mcp/errors.py +8 -0
- package/src/harness_context/mcp/lifecycle.py +72 -0
- package/src/harness_context/mcp/middleware.py +57 -0
- package/src/harness_context/mcp/server.py +3 -0
- package/src/harness_context/mcp/tool_handlers/__init__.py +7 -0
- package/src/harness_context/mcp/tool_handlers/context.py +16 -0
- package/src/harness_context/mcp/tool_handlers/ecc.py +8 -0
- package/src/harness_context/mcp/tool_handlers/handoffs.py +20 -0
- package/src/harness_context/mcp/tool_handlers/memory.py +16 -0
- package/src/harness_context/mcp/tool_handlers/workspace.py +12 -0
- package/src/harness_context/mcp/tools.py +15 -0
- package/src/harness_context/observability/__init__.py +6 -0
- package/src/harness_context/observability/events.py +25 -0
- package/src/harness_context/observability/metrics.py +20 -0
- package/src/harness_context/paths.py +35 -0
- package/src/harness_context/runtime.py +127 -0
- package/src/harness_context/schemas.py +38 -0
- package/src/harness_context/security/__init__.py +3 -0
- package/src/harness_context/security/secret_patterns.py +15 -0
- package/src/harness_context/server.py +1077 -0
- package/src/harness_context/storage/__init__.py +6 -0
- package/src/harness_context/storage/migrations.py +24 -0
- package/src/harness_context/storage/pins.py +10 -0
- package/src/harness_context/storage/snapshots.py +149 -0
- package/src/harness_context/tokenize.py +12 -0
- package/src/harness_context/topology.py +65 -0
- package/src/harness_context/watcher/__init__.py +3 -0
- package/src/harness_context/watcher/service.py +32 -0
- package/src/harness_context/workspace/__init__.py +13 -0
- package/src/harness_context/workspace/identity.py +9 -0
- package/src/harness_context/workspace/lock.py +24 -0
- package/src/harness_context/workspace/policy.py +3 -0
- package/src/harness_context/workspace/roots.py +84 -0
- package/src/harness_context/workspace/state.py +35 -0
- package/src/memory/episodic.py +257 -0
- package/src/memory/vector_store.py +104 -0
- package/src/retrieval/bm25.py +23 -0
- package/src/retrieval/cache.py +76 -0
- package/src/retrieval/embeddings.py +75 -0
- package/src/retrieval/graph.py +45 -0
- package/src/retrieval/reranker.py +78 -0
- package/src/retrieval/tokenize.py +11 -0
|
@@ -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,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,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,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")
|