xrefkit 0.3.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.
- xrefkit/__init__.py +5 -0
- xrefkit/__main__.py +5 -0
- xrefkit/catalog_cli.py +57 -0
- xrefkit/cli.py +71 -0
- xrefkit/contracts.py +297 -0
- xrefkit/ctx.py +160 -0
- xrefkit/dashboard.py +1220 -0
- xrefkit/discovery.py +85 -0
- xrefkit/gate.py +428 -0
- xrefkit/goalstate.py +555 -0
- xrefkit/hashing.py +18 -0
- xrefkit/import_skill.py +469 -0
- xrefkit/instance.py +133 -0
- xrefkit/loaders.py +77 -0
- xrefkit/mcp/__init__.py +26 -0
- xrefkit/mcp/audit.py +168 -0
- xrefkit/mcp/bootstrap.py +337 -0
- xrefkit/mcp/catalog.py +2638 -0
- xrefkit/mcp/cli.py +173 -0
- xrefkit/mcp/client_cache.py +356 -0
- xrefkit/mcp/context_registry.py +277 -0
- xrefkit/mcp/contracts.py +243 -0
- xrefkit/mcp/dist.py +234 -0
- xrefkit/mcp/ownership.py +246 -0
- xrefkit/mcp/repository.py +217 -0
- xrefkit/mcp/schemas.py +349 -0
- xrefkit/mcp/server.py +773 -0
- xrefkit/mcp/startup_contract_pack.py +154 -0
- xrefkit/mcp_tools.py +47 -0
- xrefkit/models/__init__.py +41 -0
- xrefkit/models/common.py +185 -0
- xrefkit/models/effective_bundle.py +131 -0
- xrefkit/models/local_manifest.py +217 -0
- xrefkit/models/package_manifest.py +126 -0
- xrefkit/models/run_log.py +276 -0
- xrefkit/models/server_config.py +160 -0
- xrefkit/models/skill_definition.py +131 -0
- xrefkit/operations_cli.py +670 -0
- xrefkit/ownership.py +276 -0
- xrefkit/packmeta.py +289 -0
- xrefkit/registry.py +334 -0
- xrefkit/resolver.py +252 -0
- xrefkit/resource_provider.py +187 -0
- xrefkit/resources/base/contracts.json +178 -0
- xrefkit/resources/base/current.json +6 -0
- xrefkit/resources/base/generations/7a682a5272907354/contracts.json +178 -0
- xrefkit/resources/base/generations/7a682a5272907354/model_body.md +22 -0
- xrefkit/resources/base/generations/9929294385ccb7b0/contracts.json +178 -0
- xrefkit/resources/base/generations/9929294385ccb7b0/model_body.md +22 -0
- xrefkit/resources/base/model_body.md +22 -0
- xrefkit/runlog.py +45 -0
- xrefkit/skillmeta.py +1034 -0
- xrefkit/skillrun.py +2381 -0
- xrefkit/structure_catalog.py +199 -0
- xrefkit/tools/__init__.py +119 -0
- xrefkit/tools/__main__.py +4 -0
- xrefkit/v2_cli.py +130 -0
- xrefkit/workspace.py +117 -0
- xrefkit/xref.py +1048 -0
- xrefkit-0.3.0.dist-info/METADATA +203 -0
- xrefkit-0.3.0.dist-info/RECORD +65 -0
- xrefkit-0.3.0.dist-info/WHEEL +5 -0
- xrefkit-0.3.0.dist-info/entry_points.txt +2 -0
- xrefkit-0.3.0.dist-info/licenses/LICENSE +21 -0
- xrefkit-0.3.0.dist-info/top_level.txt +1 -0
xrefkit/mcp/cli.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .catalog import XRefCatalog
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv: list[str] | None = None) -> int:
|
|
11
|
+
parser = argparse.ArgumentParser(prog="xrefkit-mcp-catalog")
|
|
12
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
13
|
+
|
|
14
|
+
def add_repo_arguments(command: argparse.ArgumentParser) -> None:
|
|
15
|
+
command.add_argument("--repo", required=True)
|
|
16
|
+
command.add_argument(
|
|
17
|
+
"--domain-knowledge-root",
|
|
18
|
+
action="append",
|
|
19
|
+
default=[],
|
|
20
|
+
help="External XID-addressable domain knowledge root. Can be repeated.",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
catalog = sub.add_parser("catalog", help="build and print catalog summary")
|
|
24
|
+
add_repo_arguments(catalog)
|
|
25
|
+
|
|
26
|
+
identity = sub.add_parser(
|
|
27
|
+
"repository-identity",
|
|
28
|
+
help="print the repository cache identity",
|
|
29
|
+
)
|
|
30
|
+
add_repo_arguments(identity)
|
|
31
|
+
|
|
32
|
+
startup = sub.add_parser("startup-context", help="print required startup references")
|
|
33
|
+
add_repo_arguments(startup)
|
|
34
|
+
|
|
35
|
+
pack_hashes = sub.add_parser(
|
|
36
|
+
"startup-pack-hashes",
|
|
37
|
+
help="print the Based On hash lines for the startup contract pack document",
|
|
38
|
+
)
|
|
39
|
+
add_repo_arguments(pack_hashes)
|
|
40
|
+
|
|
41
|
+
pack_check = sub.add_parser(
|
|
42
|
+
"check-startup-pack",
|
|
43
|
+
help="exit non-zero when the startup contract pack is stale against its sources",
|
|
44
|
+
)
|
|
45
|
+
add_repo_arguments(pack_check)
|
|
46
|
+
|
|
47
|
+
knowledge = sub.add_parser("search-knowledge", help="search knowledge catalog")
|
|
48
|
+
add_repo_arguments(knowledge)
|
|
49
|
+
knowledge.add_argument("--query", required=True)
|
|
50
|
+
knowledge.add_argument("--limit", type=int, default=10)
|
|
51
|
+
|
|
52
|
+
expand = sub.add_parser("expand-knowledge", help="expand one knowledge body")
|
|
53
|
+
add_repo_arguments(expand)
|
|
54
|
+
expand.add_argument("--xid", required=True)
|
|
55
|
+
|
|
56
|
+
document = sub.add_parser("get-document", help="expand any managed Markdown document by XID")
|
|
57
|
+
add_repo_arguments(document)
|
|
58
|
+
document.add_argument("--xid", required=True)
|
|
59
|
+
document.add_argument("--known-version")
|
|
60
|
+
|
|
61
|
+
context = sub.add_parser("build-knowledge-context", help="expand bounded knowledge context")
|
|
62
|
+
add_repo_arguments(context)
|
|
63
|
+
context.add_argument("--query", required=True)
|
|
64
|
+
context.add_argument("--limit", type=int, default=5)
|
|
65
|
+
|
|
66
|
+
skills = sub.add_parser("list-skills", help="list Skill catalog")
|
|
67
|
+
add_repo_arguments(skills)
|
|
68
|
+
skills.add_argument("--limit", type=int)
|
|
69
|
+
skills.add_argument(
|
|
70
|
+
"--include-content",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="Include full meta.md and SKILL.md bodies (metadata-only by default)",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
skill = sub.add_parser("get-skill", help="get one Skill catalog entry with transferred content")
|
|
76
|
+
add_repo_arguments(skill)
|
|
77
|
+
skill.add_argument("--skill-id", required=True)
|
|
78
|
+
|
|
79
|
+
rank = sub.add_parser("rank-skills", help="rank Skill candidates for a purpose")
|
|
80
|
+
add_repo_arguments(rank)
|
|
81
|
+
rank.add_argument("--purpose", required=True)
|
|
82
|
+
rank.add_argument("--limit", type=int, default=5)
|
|
83
|
+
|
|
84
|
+
contracts = sub.add_parser("tool-contracts", help="list read-only tool contracts")
|
|
85
|
+
add_repo_arguments(contracts)
|
|
86
|
+
|
|
87
|
+
tool_manifest = sub.add_parser("client-tool-manifest", help="list distributable client-side tool files")
|
|
88
|
+
add_repo_arguments(tool_manifest)
|
|
89
|
+
|
|
90
|
+
tool_file = sub.add_parser("get-client-tool-file", help="get one distributable client-side tool file")
|
|
91
|
+
add_repo_arguments(tool_file)
|
|
92
|
+
tool_file.add_argument("--path", required=True)
|
|
93
|
+
|
|
94
|
+
tool_bundle = sub.add_parser("client-tool-bundle", help="get all distributable client-side tool files")
|
|
95
|
+
add_repo_arguments(tool_bundle)
|
|
96
|
+
|
|
97
|
+
tool_package = sub.add_parser("client-tool-pip-package", help="get a pip-installable client tool package")
|
|
98
|
+
add_repo_arguments(tool_package)
|
|
99
|
+
|
|
100
|
+
version_check = sub.add_parser("check-client-tool-versions", help="check installed client tool versions")
|
|
101
|
+
add_repo_arguments(version_check)
|
|
102
|
+
version_check.add_argument(
|
|
103
|
+
"--installed",
|
|
104
|
+
action="append",
|
|
105
|
+
default=[],
|
|
106
|
+
help="Installed package version as package_id=version. Can be repeated.",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
args = parser.parse_args(argv)
|
|
110
|
+
model = XRefCatalog.build(Path(args.repo), args.domain_knowledge_root)
|
|
111
|
+
|
|
112
|
+
if args.command == "catalog":
|
|
113
|
+
payload = {
|
|
114
|
+
"catalog_version": model.catalog_version,
|
|
115
|
+
"knowledge_count": len(model.knowledge),
|
|
116
|
+
"skill_count": len(model.skills),
|
|
117
|
+
"tool_contract_count": len(model.tools),
|
|
118
|
+
}
|
|
119
|
+
elif args.command == "repository-identity":
|
|
120
|
+
payload = model.get_repository_identity()
|
|
121
|
+
elif args.command == "startup-context":
|
|
122
|
+
payload = model.get_startup_context()
|
|
123
|
+
elif args.command == "startup-pack-hashes":
|
|
124
|
+
references = model.get_startup_context()["references"]
|
|
125
|
+
for reference in references:
|
|
126
|
+
print(f"- {reference['xid']}: `{reference['content_hash']}`")
|
|
127
|
+
return 0
|
|
128
|
+
elif args.command == "check-startup-pack":
|
|
129
|
+
pack = model.get_startup_context()["startup_contract_pack"]
|
|
130
|
+
payload = {
|
|
131
|
+
"pack_source": pack["pack_source"],
|
|
132
|
+
"pack_doc_xid": pack["pack_doc_xid"],
|
|
133
|
+
"stale": pack["stale"],
|
|
134
|
+
"stale_sources": pack["stale_sources"],
|
|
135
|
+
}
|
|
136
|
+
print(json.dumps(payload, ensure_ascii=True, indent=2))
|
|
137
|
+
return 1 if pack["stale"] else 0
|
|
138
|
+
elif args.command == "search-knowledge":
|
|
139
|
+
payload = model.search_knowledge_catalog(args.query, args.limit)
|
|
140
|
+
elif args.command == "expand-knowledge":
|
|
141
|
+
payload = model.expand_knowledge(args.xid)
|
|
142
|
+
elif args.command == "get-document":
|
|
143
|
+
payload = model.get_document_by_xid(args.xid, args.known_version)
|
|
144
|
+
elif args.command == "build-knowledge-context":
|
|
145
|
+
payload = model.build_knowledge_context(args.query, args.limit)
|
|
146
|
+
elif args.command == "list-skills":
|
|
147
|
+
payload = model.list_skills(args.limit, args.include_content)
|
|
148
|
+
elif args.command == "get-skill":
|
|
149
|
+
payload = model.get_skill(args.skill_id)
|
|
150
|
+
elif args.command == "rank-skills":
|
|
151
|
+
payload = model.rank_skills_for_purpose(args.purpose, args.limit)
|
|
152
|
+
elif args.command == "tool-contracts":
|
|
153
|
+
payload = model.list_tool_contracts()
|
|
154
|
+
elif args.command == "client-tool-manifest":
|
|
155
|
+
payload = model.get_client_tool_manifest()
|
|
156
|
+
elif args.command == "get-client-tool-file":
|
|
157
|
+
payload = model.get_client_tool_file(args.path)
|
|
158
|
+
elif args.command == "client-tool-bundle":
|
|
159
|
+
payload = model.get_client_tool_bundle()
|
|
160
|
+
elif args.command == "client-tool-pip-package":
|
|
161
|
+
payload = model.get_client_tool_pip_package()
|
|
162
|
+
elif args.command == "check-client-tool-versions":
|
|
163
|
+
installed = dict(item.split("=", 1) for item in args.installed)
|
|
164
|
+
payload = model.check_client_tool_versions(installed)
|
|
165
|
+
else:
|
|
166
|
+
parser.error(f"unknown command: {args.command}")
|
|
167
|
+
|
|
168
|
+
print(json.dumps(payload, ensure_ascii=True, indent=2))
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .repository import stable_hash
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
XID_PATTERN = re.compile(r"^[A-Za-z0-9]+$")
|
|
15
|
+
NAMESPACE_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DocumentCacheProtocolError(RuntimeError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class XidDocumentCache:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
cache_root: str | Path,
|
|
26
|
+
repository_fingerprint: str,
|
|
27
|
+
) -> None:
|
|
28
|
+
if not NAMESPACE_PATTERN.fullmatch(repository_fingerprint):
|
|
29
|
+
raise ValueError(
|
|
30
|
+
f"invalid repository fingerprint: {repository_fingerprint!r}"
|
|
31
|
+
)
|
|
32
|
+
self.cache_root = Path(cache_root)
|
|
33
|
+
self.repository_fingerprint = repository_fingerprint
|
|
34
|
+
self.cache_dir = self.cache_root / repository_fingerprint
|
|
35
|
+
|
|
36
|
+
def load(self, xid: str) -> dict[str, Any] | None:
|
|
37
|
+
path = self._entry_path(xid)
|
|
38
|
+
try:
|
|
39
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
40
|
+
document = payload["document"]
|
|
41
|
+
content_hash = payload["content_hash"]
|
|
42
|
+
if payload["repository_fingerprint"] != self.repository_fingerprint:
|
|
43
|
+
raise ValueError("cached repository fingerprint does not match")
|
|
44
|
+
if document["xid"] != xid:
|
|
45
|
+
raise ValueError("cached XID does not match the cache key")
|
|
46
|
+
if (
|
|
47
|
+
document["repository_fingerprint"]
|
|
48
|
+
!= self.repository_fingerprint
|
|
49
|
+
):
|
|
50
|
+
raise ValueError("document repository fingerprint does not match")
|
|
51
|
+
if document["content_hash"] != content_hash:
|
|
52
|
+
raise ValueError("cached content_hash does not match document")
|
|
53
|
+
if stable_hash(document["content"]) != content_hash:
|
|
54
|
+
raise ValueError("cached content hash is invalid")
|
|
55
|
+
return document
|
|
56
|
+
except FileNotFoundError:
|
|
57
|
+
return None
|
|
58
|
+
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
59
|
+
self.evict(xid)
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def known_versions(
|
|
63
|
+
self,
|
|
64
|
+
xids: Iterable[str] | None = None,
|
|
65
|
+
) -> dict[str, str]:
|
|
66
|
+
if xids is not None:
|
|
67
|
+
versions: dict[str, str] = {}
|
|
68
|
+
for xid in xids:
|
|
69
|
+
document = self.load(xid)
|
|
70
|
+
if document is not None:
|
|
71
|
+
versions[xid] = document["content_hash"]
|
|
72
|
+
return versions
|
|
73
|
+
if not self.cache_dir.exists():
|
|
74
|
+
return {}
|
|
75
|
+
versions: dict[str, str] = {}
|
|
76
|
+
for path in sorted(self.cache_dir.glob("*.json")):
|
|
77
|
+
xid = path.stem
|
|
78
|
+
if not XID_PATTERN.fullmatch(xid):
|
|
79
|
+
continue
|
|
80
|
+
document = self.load(xid)
|
|
81
|
+
if document is not None:
|
|
82
|
+
versions[xid] = document["content_hash"]
|
|
83
|
+
return versions
|
|
84
|
+
|
|
85
|
+
def store(self, document: dict[str, Any]) -> bool:
|
|
86
|
+
xid = str(document.get("xid", ""))
|
|
87
|
+
self._validate_xid(xid)
|
|
88
|
+
if (
|
|
89
|
+
document.get("repository_fingerprint")
|
|
90
|
+
!= self.repository_fingerprint
|
|
91
|
+
):
|
|
92
|
+
raise DocumentCacheProtocolError(
|
|
93
|
+
"document repository fingerprint does not match cache namespace"
|
|
94
|
+
)
|
|
95
|
+
policy = document.get("cache_policy") or {}
|
|
96
|
+
if policy.get("cache_recommended") is not True:
|
|
97
|
+
self.evict(xid)
|
|
98
|
+
return False
|
|
99
|
+
content = document.get("content")
|
|
100
|
+
version = document.get("content_hash")
|
|
101
|
+
if not isinstance(content, str) or not isinstance(version, str):
|
|
102
|
+
raise DocumentCacheProtocolError(
|
|
103
|
+
"cacheable document must include content and content_hash"
|
|
104
|
+
)
|
|
105
|
+
if stable_hash(content) != version:
|
|
106
|
+
raise DocumentCacheProtocolError(
|
|
107
|
+
"document content does not match content_hash"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
target = self._entry_path(xid)
|
|
112
|
+
payload = {
|
|
113
|
+
"schema_version": 1,
|
|
114
|
+
"repository_fingerprint": self.repository_fingerprint,
|
|
115
|
+
"xid": xid,
|
|
116
|
+
"content_hash": version,
|
|
117
|
+
"document": document,
|
|
118
|
+
}
|
|
119
|
+
serialized = json.dumps(
|
|
120
|
+
payload,
|
|
121
|
+
ensure_ascii=False,
|
|
122
|
+
indent=2,
|
|
123
|
+
sort_keys=True,
|
|
124
|
+
)
|
|
125
|
+
self._write_atomic(target, serialized)
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
def startup_versions(self) -> dict[str, str]:
|
|
129
|
+
try:
|
|
130
|
+
payload = json.loads(
|
|
131
|
+
self._startup_index_path().read_text(encoding="utf-8")
|
|
132
|
+
)
|
|
133
|
+
if payload["repository_fingerprint"] != self.repository_fingerprint:
|
|
134
|
+
raise ValueError("startup repository fingerprint does not match")
|
|
135
|
+
xids = payload["xids"]
|
|
136
|
+
if not isinstance(xids, list) or not all(
|
|
137
|
+
isinstance(xid, str) for xid in xids
|
|
138
|
+
):
|
|
139
|
+
raise ValueError("invalid startup XID index")
|
|
140
|
+
except (
|
|
141
|
+
FileNotFoundError,
|
|
142
|
+
OSError,
|
|
143
|
+
KeyError,
|
|
144
|
+
TypeError,
|
|
145
|
+
ValueError,
|
|
146
|
+
json.JSONDecodeError,
|
|
147
|
+
):
|
|
148
|
+
return {}
|
|
149
|
+
return self.known_versions(xids)
|
|
150
|
+
|
|
151
|
+
async def resolve_startup(
|
|
152
|
+
self,
|
|
153
|
+
fetch_startup: Callable[
|
|
154
|
+
[dict[str, str]],
|
|
155
|
+
Awaitable[dict[str, Any]],
|
|
156
|
+
],
|
|
157
|
+
) -> dict[str, Any]:
|
|
158
|
+
known_versions = self.startup_versions()
|
|
159
|
+
response = await fetch_startup(known_versions)
|
|
160
|
+
try:
|
|
161
|
+
references = [
|
|
162
|
+
self.materialize(reference)
|
|
163
|
+
for reference in response["references"]
|
|
164
|
+
]
|
|
165
|
+
except (DocumentCacheProtocolError, KeyError, TypeError):
|
|
166
|
+
known_versions = {}
|
|
167
|
+
response = await fetch_startup(known_versions)
|
|
168
|
+
references = [
|
|
169
|
+
self.materialize(reference)
|
|
170
|
+
for reference in response["references"]
|
|
171
|
+
]
|
|
172
|
+
result = dict(response)
|
|
173
|
+
result["references"] = references
|
|
174
|
+
self._append_startup_decision_log(response, references, known_versions)
|
|
175
|
+
self._store_startup_index(
|
|
176
|
+
[str(reference["xid"]) for reference in references]
|
|
177
|
+
)
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
def startup_decision_log_path(self) -> Path:
|
|
181
|
+
return self.cache_dir / "_startup_decisions.jsonl"
|
|
182
|
+
|
|
183
|
+
def startup_decision_log_entries(self) -> list[dict[str, Any]]:
|
|
184
|
+
try:
|
|
185
|
+
lines = self.startup_decision_log_path().read_text(
|
|
186
|
+
encoding="utf-8"
|
|
187
|
+
).splitlines()
|
|
188
|
+
except FileNotFoundError:
|
|
189
|
+
return []
|
|
190
|
+
entries: list[dict[str, Any]] = []
|
|
191
|
+
for line in lines:
|
|
192
|
+
if not line.strip():
|
|
193
|
+
continue
|
|
194
|
+
entries.append(json.loads(line))
|
|
195
|
+
return entries
|
|
196
|
+
|
|
197
|
+
def _append_startup_decision_log(
|
|
198
|
+
self,
|
|
199
|
+
response: dict[str, Any],
|
|
200
|
+
references: list[dict[str, Any]],
|
|
201
|
+
known_versions: dict[str, str],
|
|
202
|
+
) -> None:
|
|
203
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
startup_contract_pack = response.get("startup_contract_pack")
|
|
205
|
+
source_xids = _string_list(
|
|
206
|
+
startup_contract_pack.get("source_xids")
|
|
207
|
+
if isinstance(startup_contract_pack, dict)
|
|
208
|
+
else None
|
|
209
|
+
)
|
|
210
|
+
load_order_xids = _string_list(response.get("load_order"))
|
|
211
|
+
entry = {
|
|
212
|
+
"schema_version": 1,
|
|
213
|
+
"event": "startup_xid_decision",
|
|
214
|
+
"logged_at": datetime.now(UTC).isoformat(),
|
|
215
|
+
"repository_fingerprint": self.repository_fingerprint,
|
|
216
|
+
"known_version_xids": sorted(known_versions),
|
|
217
|
+
"load_order_xids": load_order_xids,
|
|
218
|
+
"startup_contract_pack_source_xids": source_xids,
|
|
219
|
+
"reference_xids": [str(reference["xid"]) for reference in references],
|
|
220
|
+
"client_decision_xids": _unique_strings(
|
|
221
|
+
[
|
|
222
|
+
*load_order_xids,
|
|
223
|
+
*source_xids,
|
|
224
|
+
*(str(reference["xid"]) for reference in references),
|
|
225
|
+
]
|
|
226
|
+
),
|
|
227
|
+
}
|
|
228
|
+
with self.startup_decision_log_path().open(
|
|
229
|
+
mode="a",
|
|
230
|
+
encoding="utf-8",
|
|
231
|
+
) as log_file:
|
|
232
|
+
log_file.write(json.dumps(entry, ensure_ascii=False, sort_keys=True))
|
|
233
|
+
log_file.write("\n")
|
|
234
|
+
|
|
235
|
+
def _store_startup_index(self, xids: list[str]) -> None:
|
|
236
|
+
for xid in xids:
|
|
237
|
+
self._validate_xid(xid)
|
|
238
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
serialized = json.dumps(
|
|
240
|
+
{
|
|
241
|
+
"schema_version": 1,
|
|
242
|
+
"repository_fingerprint": self.repository_fingerprint,
|
|
243
|
+
"xids": xids,
|
|
244
|
+
},
|
|
245
|
+
ensure_ascii=False,
|
|
246
|
+
indent=2,
|
|
247
|
+
sort_keys=True,
|
|
248
|
+
)
|
|
249
|
+
self._write_atomic(self._startup_index_path(), serialized)
|
|
250
|
+
|
|
251
|
+
def _write_atomic(self, target: Path, serialized: str) -> None:
|
|
252
|
+
temporary_path: Path | None = None
|
|
253
|
+
try:
|
|
254
|
+
with tempfile.NamedTemporaryFile(
|
|
255
|
+
mode="w",
|
|
256
|
+
encoding="utf-8",
|
|
257
|
+
dir=self.cache_dir,
|
|
258
|
+
prefix=f".{target.stem}.",
|
|
259
|
+
suffix=".tmp",
|
|
260
|
+
delete=False,
|
|
261
|
+
) as temporary:
|
|
262
|
+
temporary.write(serialized)
|
|
263
|
+
temporary_path = Path(temporary.name)
|
|
264
|
+
temporary_path.replace(target)
|
|
265
|
+
finally:
|
|
266
|
+
if temporary_path is not None and temporary_path.exists():
|
|
267
|
+
temporary_path.unlink()
|
|
268
|
+
|
|
269
|
+
def evict(self, xid: str) -> None:
|
|
270
|
+
path = self._entry_path(xid)
|
|
271
|
+
try:
|
|
272
|
+
path.unlink()
|
|
273
|
+
except FileNotFoundError:
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
async def resolve(
|
|
277
|
+
self,
|
|
278
|
+
xid: str,
|
|
279
|
+
fetch_document: Callable[[str, str | None], Awaitable[dict[str, Any]]],
|
|
280
|
+
) -> dict[str, Any]:
|
|
281
|
+
cached = self.load(xid)
|
|
282
|
+
known_version = cached["content_hash"] if cached else None
|
|
283
|
+
response = await fetch_document(xid, known_version)
|
|
284
|
+
|
|
285
|
+
if response.get("cache_status") == "not_modified":
|
|
286
|
+
try:
|
|
287
|
+
return self.materialize(response)
|
|
288
|
+
except DocumentCacheProtocolError:
|
|
289
|
+
response = await fetch_document(xid, None)
|
|
290
|
+
|
|
291
|
+
return self.materialize(response)
|
|
292
|
+
|
|
293
|
+
def materialize(self, response: dict[str, Any]) -> dict[str, Any]:
|
|
294
|
+
xid = str(response.get("xid", ""))
|
|
295
|
+
self._validate_xid(xid)
|
|
296
|
+
if (
|
|
297
|
+
response.get("repository_fingerprint")
|
|
298
|
+
!= self.repository_fingerprint
|
|
299
|
+
):
|
|
300
|
+
raise DocumentCacheProtocolError(
|
|
301
|
+
"response repository fingerprint does not match cache namespace"
|
|
302
|
+
)
|
|
303
|
+
if response.get("included_in_startup_contract_pack") is True:
|
|
304
|
+
result = dict(response)
|
|
305
|
+
result["client_cache_status"] = "startup_contract_pack"
|
|
306
|
+
return result
|
|
307
|
+
if response.get("cache_status") == "not_modified":
|
|
308
|
+
cached = self.load(xid)
|
|
309
|
+
if cached is None:
|
|
310
|
+
raise DocumentCacheProtocolError(
|
|
311
|
+
"document response omitted content without a usable cache entry"
|
|
312
|
+
)
|
|
313
|
+
result = dict(cached)
|
|
314
|
+
result.update(response)
|
|
315
|
+
result["content"] = cached["content"]
|
|
316
|
+
result["content_omitted"] = False
|
|
317
|
+
result["client_cache_status"] = "hit"
|
|
318
|
+
return result
|
|
319
|
+
|
|
320
|
+
if "content" not in response:
|
|
321
|
+
raise DocumentCacheProtocolError(
|
|
322
|
+
"document response omitted content without a usable cache entry"
|
|
323
|
+
)
|
|
324
|
+
stored = self.store(response)
|
|
325
|
+
result = dict(response)
|
|
326
|
+
result["client_cache_status"] = "refreshed" if stored else "bypassed"
|
|
327
|
+
return result
|
|
328
|
+
|
|
329
|
+
def _entry_path(self, xid: str) -> Path:
|
|
330
|
+
self._validate_xid(xid)
|
|
331
|
+
return self.cache_dir / f"{xid}.json"
|
|
332
|
+
|
|
333
|
+
def _startup_index_path(self) -> Path:
|
|
334
|
+
return self.cache_dir / "_startup.json"
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def _validate_xid(xid: str) -> None:
|
|
338
|
+
if not XID_PATTERN.fullmatch(xid):
|
|
339
|
+
raise ValueError(f"invalid XID for cache key: {xid!r}")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _string_list(value: Any) -> list[str]:
|
|
343
|
+
if not isinstance(value, list):
|
|
344
|
+
return []
|
|
345
|
+
return [item for item in value if isinstance(item, str)]
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _unique_strings(values: Iterable[str]) -> list[str]:
|
|
349
|
+
seen: set[str] = set()
|
|
350
|
+
result: list[str] = []
|
|
351
|
+
for value in values:
|
|
352
|
+
if value in seen:
|
|
353
|
+
continue
|
|
354
|
+
seen.add(value)
|
|
355
|
+
result.append(value)
|
|
356
|
+
return result
|