rememberstack 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.
- rememberstack/__init__.py +9 -0
- rememberstack/adapters/__init__.py +42 -0
- rememberstack/adapters/codex_writer.py +221 -0
- rememberstack/adapters/markitdown_converter.py +42 -0
- rememberstack/adapters/openrouter.py +136 -0
- rememberstack/adapters/selfhost/__init__.py +54 -0
- rememberstack/adapters/selfhost/forget.py +66 -0
- rememberstack/adapters/selfhost/git.py +374 -0
- rememberstack/adapters/selfhost/lance.py +328 -0
- rememberstack/adapters/selfhost/minio.py +279 -0
- rememberstack/adapters/selfhost/mounts.py +249 -0
- rememberstack/adapters/selfhost/object_store.py +130 -0
- rememberstack/adapters/selfhost/projection.py +80 -0
- rememberstack/adapters/selfhost/queue.py +137 -0
- rememberstack/adapters/selfhost/telemetry.py +45 -0
- rememberstack/adapters/selfhost/watcher.py +70 -0
- rememberstack/adapters/testing/__init__.py +15 -0
- rememberstack/adapters/testing/cost_meter.py +13 -0
- rememberstack/adapters/testing/model_provider.py +83 -0
- rememberstack/adapters/testing/queue.py +43 -0
- rememberstack/adapters/testing/telemetry.py +22 -0
- rememberstack/client.py +19 -0
- rememberstack/core/__init__.py +127 -0
- rememberstack/core/blockizer.py +189 -0
- rememberstack/core/chunker.py +216 -0
- rememberstack/core/consumption_skill.py +275 -0
- rememberstack/core/conversion.py +76 -0
- rememberstack/core/core_manifest.py +598 -0
- rememberstack/core/extension_packs.py +124 -0
- rememberstack/core/forget.py +17 -0
- rememberstack/core/knowledge_authored.py +276 -0
- rememberstack/core/knowledge_compile.py +215 -0
- rememberstack/core/knowledge_fact_sheet.py +210 -0
- rememberstack/core/knowledge_hashing.py +68 -0
- rememberstack/core/knowledge_planner.py +64 -0
- rememberstack/core/knowledge_writer.py +175 -0
- rememberstack/core/ranking.py +200 -0
- rememberstack/core/recipe_linter.py +149 -0
- rememberstack/core/section_snap.py +209 -0
- rememberstack/core/storage_routing.py +27 -0
- rememberstack/eval/__init__.py +53 -0
- rememberstack/eval/consumption.py +141 -0
- rememberstack/eval/contradiction.py +184 -0
- rememberstack/eval/harness.py +136 -0
- rememberstack/eval/lifecycle.py +400 -0
- rememberstack/eval/operational_scale.py +49 -0
- rememberstack/eval/resolution.py +255 -0
- rememberstack/eval/retrieval_spikes.py +50 -0
- rememberstack/eval/skeleton.py +231 -0
- rememberstack/llm/__init__.py +1 -0
- rememberstack/model/__init__.py +589 -0
- rememberstack/model/adjudication.py +100 -0
- rememberstack/model/auth.py +27 -0
- rememberstack/model/blocks.py +30 -0
- rememberstack/model/chunks.py +190 -0
- rememberstack/model/claims.py +162 -0
- rememberstack/model/client.py +98 -0
- rememberstack/model/clustering.py +54 -0
- rememberstack/model/component_version.py +124 -0
- rememberstack/model/consumption.py +88 -0
- rememberstack/model/conversion.py +31 -0
- rememberstack/model/deployment.py +53 -0
- rememberstack/model/documents.py +168 -0
- rememberstack/model/envelope.py +513 -0
- rememberstack/model/evaluation.py +72 -0
- rememberstack/model/forget.py +143 -0
- rememberstack/model/git.py +13 -0
- rememberstack/model/knowledge.py +840 -0
- rememberstack/model/knowledge_authored.py +325 -0
- rememberstack/model/knowledge_planner.py +431 -0
- rememberstack/model/lifecycle.py +42 -0
- rememberstack/model/model_provider.py +78 -0
- rememberstack/model/mounts.py +24 -0
- rememberstack/model/object_store.py +21 -0
- rememberstack/model/operational_scale.py +59 -0
- rememberstack/model/operations.py +153 -0
- rememberstack/model/processing.py +228 -0
- rememberstack/model/queue.py +73 -0
- rememberstack/model/recipes.py +83 -0
- rememberstack/model/relations.py +79 -0
- rememberstack/model/resolution.py +83 -0
- rememberstack/model/retrieval_spikes.py +62 -0
- rememberstack/model/sections.py +120 -0
- rememberstack/model/telemetry.py +30 -0
- rememberstack/ports/__init__.py +29 -0
- rememberstack/ports/auth.py +16 -0
- rememberstack/ports/connector.py +23 -0
- rememberstack/ports/cost_meter.py +17 -0
- rememberstack/ports/forget.py +20 -0
- rememberstack/ports/git.py +20 -0
- rememberstack/ports/model_provider.py +28 -0
- rememberstack/ports/mounts.py +16 -0
- rememberstack/ports/object_store.py +27 -0
- rememberstack/ports/p1_index.py +92 -0
- rememberstack/ports/purge.py +93 -0
- rememberstack/ports/queue.py +23 -0
- rememberstack/ports/telemetry.py +21 -0
- rememberstack/profiles/__init__.py +22 -0
- rememberstack/profiles/selfhost.py +324 -0
- rememberstack/profiles/selfhost_forget.py +158 -0
- rememberstack/profiles/selfhost_operations.py +95 -0
- rememberstack/py.typed +1 -0
- rememberstack/spine/__init__.py +93 -0
- rememberstack/spine/admission.py +26 -0
- rememberstack/spine/backfill.py +168 -0
- rememberstack/spine/catalog_contract.py +742 -0
- rememberstack/spine/chunk_catalog.py +237 -0
- rememberstack/spine/claim_catalog.py +298 -0
- rememberstack/spine/clustering.py +740 -0
- rememberstack/spine/component_versions.py +208 -0
- rememberstack/spine/consumption.py +81 -0
- rememberstack/spine/deployment_bootstrap.py +445 -0
- rememberstack/spine/document_catalog.py +621 -0
- rememberstack/spine/entity_registry.py +205 -0
- rememberstack/spine/extension_packs.py +220 -0
- rememberstack/spine/fact_catalog.py +571 -0
- rememberstack/spine/forget.py +1753 -0
- rememberstack/spine/knowledge.py +5467 -0
- rememberstack/spine/lifecycle.py +1071 -0
- rememberstack/spine/migrations/__init__.py +1 -0
- rememberstack/spine/migrations/_helpers.py +153 -0
- rememberstack/spine/migrations/env.py +58 -0
- rememberstack/spine/migrations/script.py.mako +27 -0
- rememberstack/spine/migrations/versions/__init__.py +1 -0
- rememberstack/spine/migrations/versions/p0_02_0001_extensions_enums.py +189 -0
- rememberstack/spine/migrations/versions/p0_02_0002_infrastructure_registries.py +321 -0
- rememberstack/spine/migrations/versions/p0_02_0003_entities_evaluation_e0_e1.py +631 -0
- rememberstack/spine/migrations/versions/p0_02_0004_claims_facts_evidence.py +411 -0
- rememberstack/spine/migrations/versions/p0_02_0005_projection_knowledge_retrieval.py +391 -0
- rememberstack/spine/migrations/versions/p0_02_0006_partitions_views.py +158 -0
- rememberstack/spine/migrations/versions/p2_06_0007_invalidated_outcome.py +26 -0
- rememberstack/spine/migrations/versions/p3_01_0008_document_version_target.py +58 -0
- rememberstack/spine/migrations/versions/p3_05_0009_reconcile_stage.py +27 -0
- rememberstack/spine/migrations/versions/p3_07_0010_lifecycle_eval_suite.py +25 -0
- rememberstack/spine/migrations/versions/p4_01_0011_survivor_view_rewrite.py +57 -0
- rememberstack/spine/migrations/versions/p6_02_0012_knowledge_compile_recovery.py +58 -0
- rememberstack/spine/migrations/versions/p6_04_0013_knowledge_writer_ledger.py +46 -0
- rememberstack/spine/migrations/versions/p6_05_0014_knowledge_planner_runtime.py +217 -0
- rememberstack/spine/migrations/versions/p6_06_0015_authored_dispatch_runtime.py +38 -0
- rememberstack/spine/migrations/versions/p7_02_0016_operational_eval_suite.py +19 -0
- rememberstack/spine/migrations/versions/p7_05_0017_hard_forget.py +55 -0
- rememberstack/spine/observation_adjudication.py +778 -0
- rememberstack/spine/operations.py +298 -0
- rememberstack/spine/projection.py +662 -0
- rememberstack/spine/recipes.py +276 -0
- rememberstack/spine/resolver.py +763 -0
- rememberstack/spine/review.py +650 -0
- rememberstack/spine/settings.py +22 -0
- rememberstack/spine/supersession.py +510 -0
- rememberstack/spine/sync.py +128 -0
- rememberstack/spine/work_ledger.py +816 -0
- rememberstack/surfaces/__init__.py +110 -0
- rememberstack/surfaces/cli.py +447 -0
- rememberstack/surfaces/consumption_skill.py +87 -0
- rememberstack/surfaces/graph_queries.py +698 -0
- rememberstack/surfaces/http_api.py +377 -0
- rememberstack/surfaces/mcp.py +67 -0
- rememberstack/surfaces/query_engine.py +1591 -0
- rememberstack/surfaces/recipe_executor.py +185 -0
- rememberstack/surfaces/recipe_surface.py +219 -0
- rememberstack/surfaces/remote_mcp.py +133 -0
- rememberstack/surfaces/sdk.py +324 -0
- rememberstack/workers/__init__.py +155 -0
- rememberstack/workers/base.py +312 -0
- rememberstack/workers/e0.py +577 -0
- rememberstack/workers/e1.py +425 -0
- rememberstack/workers/e2.py +525 -0
- rememberstack/workers/e3.py +434 -0
- rememberstack/workers/forget.py +299 -0
- rememberstack/workers/knowledge_authored.py +146 -0
- rememberstack/workers/knowledge_driver.py +735 -0
- rememberstack/workers/knowledge_fact_sheet.py +123 -0
- rememberstack/workers/knowledge_planner.py +325 -0
- rememberstack/workers/knowledge_writer.py +393 -0
- rememberstack/workers/operations.py +42 -0
- rememberstack/workers/p1.py +234 -0
- rememberstack/workers/p2.py +513 -0
- rememberstack/workers/p2_analytics.py +276 -0
- rememberstack/workers/p3.py +673 -0
- rememberstack/workers/reconcile.py +485 -0
- rememberstack/workers/sync.py +168 -0
- rememberstack-0.1.0.dist-info/METADATA +213 -0
- rememberstack-0.1.0.dist-info/RECORD +186 -0
- rememberstack-0.1.0.dist-info/WHEEL +4 -0
- rememberstack-0.1.0.dist-info/entry_points.txt +2 -0
- rememberstack-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""S3-compatible MinIO object storage for the self-host profile."""
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
from typing import NotRequired
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
from typing import TypedDict
|
|
7
|
+
|
|
8
|
+
import boto3
|
|
9
|
+
from botocore.client import Config
|
|
10
|
+
from botocore.exceptions import ClientError
|
|
11
|
+
from pydantic import SecretStr
|
|
12
|
+
from pydantic_settings import BaseSettings
|
|
13
|
+
from pydantic_settings import SettingsConfigDict
|
|
14
|
+
|
|
15
|
+
from rememberstack.model import ObjectAlreadyExistsError
|
|
16
|
+
from rememberstack.model import ObjectKey
|
|
17
|
+
from rememberstack.model import ObjectKeyEscapesRootError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MinIOSettings(BaseSettings):
|
|
21
|
+
"""Connection settings for the self-host S3-compatible object store."""
|
|
22
|
+
|
|
23
|
+
model_config = SettingsConfigDict(env_prefix="REMEMBERSTACK_MINIO_", extra="ignore")
|
|
24
|
+
|
|
25
|
+
endpoint_url: str
|
|
26
|
+
access_key: SecretStr
|
|
27
|
+
secret_key: SecretStr
|
|
28
|
+
region: str = "us-east-1"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _StreamingBody(Protocol):
|
|
32
|
+
"""The two response-body operations used by this adapter."""
|
|
33
|
+
|
|
34
|
+
def read(self) -> bytes:
|
|
35
|
+
"""Read the complete response body."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def close(self) -> None:
|
|
39
|
+
"""Release the underlying HTTP connection."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _GetObjectOutput(TypedDict):
|
|
44
|
+
"""The fields consumed from an S3 GetObject response."""
|
|
45
|
+
|
|
46
|
+
Body: _StreamingBody
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _HeadObjectOutput(TypedDict):
|
|
50
|
+
"""The fields consumed from an S3 HeadObject response."""
|
|
51
|
+
|
|
52
|
+
Metadata: NotRequired[dict[str, str]]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _ListedObject(TypedDict):
|
|
56
|
+
"""One object identity returned by ListObjectsV2."""
|
|
57
|
+
|
|
58
|
+
Key: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _ListObjectsOutput(TypedDict):
|
|
62
|
+
"""The fields consumed from one ListObjectsV2 response page."""
|
|
63
|
+
|
|
64
|
+
Contents: NotRequired[list[_ListedObject]]
|
|
65
|
+
IsTruncated: NotRequired[bool]
|
|
66
|
+
NextContinuationToken: NotRequired[str]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _S3Client(Protocol):
|
|
70
|
+
"""The narrow boto3 client subset the MinIO adapter owns."""
|
|
71
|
+
|
|
72
|
+
def head_bucket(self, *, Bucket: str) -> object:
|
|
73
|
+
"""Check that one bucket is reachable."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
def create_bucket(self, *, Bucket: str) -> object:
|
|
77
|
+
"""Create one bucket."""
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
def get_object(self, *, Bucket: str, Key: str) -> _GetObjectOutput:
|
|
81
|
+
"""Read one object."""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
def put_object(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
Bucket: str,
|
|
88
|
+
Key: str,
|
|
89
|
+
Body: bytes,
|
|
90
|
+
IfNoneMatch: str,
|
|
91
|
+
Metadata: dict[str, str],
|
|
92
|
+
) -> object:
|
|
93
|
+
"""Conditionally create one immutable object."""
|
|
94
|
+
...
|
|
95
|
+
|
|
96
|
+
def head_object(self, *, Bucket: str, Key: str) -> _HeadObjectOutput:
|
|
97
|
+
"""Read one object's metadata."""
|
|
98
|
+
...
|
|
99
|
+
|
|
100
|
+
def delete_object(self, *, Bucket: str, Key: str) -> object:
|
|
101
|
+
"""Delete one object idempotently."""
|
|
102
|
+
...
|
|
103
|
+
|
|
104
|
+
def list_objects_v2(
|
|
105
|
+
self, *, Bucket: str, Prefix: str, ContinuationToken: str = ""
|
|
106
|
+
) -> _ListObjectsOutput:
|
|
107
|
+
"""List one page beneath a key prefix."""
|
|
108
|
+
...
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class MinIOObjectStore:
|
|
112
|
+
"""Immutable objects in one explicitly selected MinIO bucket."""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
*,
|
|
117
|
+
bucket: str,
|
|
118
|
+
settings: MinIOSettings | None = None,
|
|
119
|
+
client: _S3Client | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Bind one bucket to either injected test client or configured MinIO."""
|
|
122
|
+
if not bucket:
|
|
123
|
+
raise ValueError("a MinIO object store requires a non-empty bucket")
|
|
124
|
+
if client is None and settings is None:
|
|
125
|
+
raise ValueError("MinIO settings are required when no client is injected")
|
|
126
|
+
self._bucket = bucket
|
|
127
|
+
self._client = client or _client(settings=cast("MinIOSettings", settings))
|
|
128
|
+
|
|
129
|
+
def ensure_bucket(self) -> None:
|
|
130
|
+
"""Provision the configured bucket if it does not exist."""
|
|
131
|
+
try:
|
|
132
|
+
self._client.head_bucket(Bucket=self._bucket)
|
|
133
|
+
return
|
|
134
|
+
except ClientError as error:
|
|
135
|
+
if _error_code(error=error) not in {"404", "NoSuchBucket", "NotFound"}:
|
|
136
|
+
raise
|
|
137
|
+
try:
|
|
138
|
+
self._client.create_bucket(Bucket=self._bucket)
|
|
139
|
+
except ClientError as error:
|
|
140
|
+
if _error_code(error=error) not in {
|
|
141
|
+
"BucketAlreadyExists",
|
|
142
|
+
"BucketAlreadyOwnedByYou",
|
|
143
|
+
}:
|
|
144
|
+
raise
|
|
145
|
+
|
|
146
|
+
def read_bytes(self, *, key: ObjectKey) -> bytes:
|
|
147
|
+
"""Read all bytes stored at one validated object key."""
|
|
148
|
+
response = self._client.get_object(
|
|
149
|
+
Bucket=self._bucket, Key=_validated_key(key=key)
|
|
150
|
+
)
|
|
151
|
+
body = response["Body"]
|
|
152
|
+
try:
|
|
153
|
+
return body.read()
|
|
154
|
+
finally:
|
|
155
|
+
body.close()
|
|
156
|
+
|
|
157
|
+
def write_bytes(
|
|
158
|
+
self, *, key: ObjectKey, content: bytes, storage_class: str | None = None
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Create immutable bytes atomically, refusing an occupied key."""
|
|
161
|
+
metadata = {} if storage_class is None else {"storage-class": storage_class}
|
|
162
|
+
try:
|
|
163
|
+
self._client.put_object(
|
|
164
|
+
Bucket=self._bucket,
|
|
165
|
+
Key=_validated_key(key=key),
|
|
166
|
+
Body=content,
|
|
167
|
+
IfNoneMatch="*",
|
|
168
|
+
Metadata=metadata,
|
|
169
|
+
)
|
|
170
|
+
except ClientError as error:
|
|
171
|
+
if _error_code(error=error) not in {
|
|
172
|
+
"409",
|
|
173
|
+
"412",
|
|
174
|
+
"ConditionalRequestConflict",
|
|
175
|
+
"PreconditionFailed",
|
|
176
|
+
}:
|
|
177
|
+
raise
|
|
178
|
+
raise ObjectAlreadyExistsError(
|
|
179
|
+
f"object key {key.root!r} is already occupied; objects are immutable"
|
|
180
|
+
) from error
|
|
181
|
+
|
|
182
|
+
def storage_class_of(self, *, key: ObjectKey) -> str | None:
|
|
183
|
+
"""Return the routing class recorded in object metadata, when present."""
|
|
184
|
+
response = self._client.head_object(
|
|
185
|
+
Bucket=self._bucket, Key=_validated_key(key=key)
|
|
186
|
+
)
|
|
187
|
+
return response.get("Metadata", {}).get("storage-class")
|
|
188
|
+
|
|
189
|
+
def purge_objects(
|
|
190
|
+
self, *, keys: tuple[ObjectKey, ...], prefixes: tuple[ObjectKey, ...]
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Idempotently delete exact keys and prefix-boundary descendants."""
|
|
193
|
+
targets = {_validated_key(key=key) for key in keys}
|
|
194
|
+
for prefix in prefixes:
|
|
195
|
+
targets.update(self._keys_under(prefix=_validated_key(key=prefix)))
|
|
196
|
+
for target in sorted(targets):
|
|
197
|
+
self._client.delete_object(Bucket=self._bucket, Key=target)
|
|
198
|
+
|
|
199
|
+
def verify_objects_purged(
|
|
200
|
+
self, *, keys: tuple[ObjectKey, ...], prefixes: tuple[ObjectKey, ...]
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Fail when any exact key or prefix-boundary descendant remains."""
|
|
203
|
+
remaining: list[str] = []
|
|
204
|
+
for key in keys:
|
|
205
|
+
normalized = _validated_key(key=key)
|
|
206
|
+
if self._exists(normalized=normalized):
|
|
207
|
+
remaining.append(normalized)
|
|
208
|
+
for prefix in prefixes:
|
|
209
|
+
remaining.extend(self._keys_under(prefix=_validated_key(key=prefix)))
|
|
210
|
+
if remaining:
|
|
211
|
+
raise RuntimeError(
|
|
212
|
+
f"object purge verification found: {sorted(remaining)!r}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _exists(self, *, normalized: str) -> bool:
|
|
216
|
+
"""Return whether one exact object exists, propagating non-absence errors."""
|
|
217
|
+
try:
|
|
218
|
+
self._client.head_object(Bucket=self._bucket, Key=normalized)
|
|
219
|
+
return True
|
|
220
|
+
except ClientError as error:
|
|
221
|
+
if _error_code(error=error) in {"404", "NoSuchKey", "NotFound"}:
|
|
222
|
+
return False
|
|
223
|
+
raise
|
|
224
|
+
|
|
225
|
+
def _keys_under(self, *, prefix: str) -> tuple[str, ...]:
|
|
226
|
+
"""Enumerate exact and descendant keys without matching sibling prefixes."""
|
|
227
|
+
boundary = prefix.rstrip("/")
|
|
228
|
+
result: list[str] = []
|
|
229
|
+
continuation = ""
|
|
230
|
+
while True:
|
|
231
|
+
page = (
|
|
232
|
+
self._client.list_objects_v2(
|
|
233
|
+
Bucket=self._bucket, Prefix=boundary, ContinuationToken=continuation
|
|
234
|
+
)
|
|
235
|
+
if continuation
|
|
236
|
+
else self._client.list_objects_v2(Bucket=self._bucket, Prefix=boundary)
|
|
237
|
+
)
|
|
238
|
+
result.extend(
|
|
239
|
+
item["Key"]
|
|
240
|
+
for item in page.get("Contents", [])
|
|
241
|
+
if item["Key"] == boundary or item["Key"].startswith(f"{boundary}/")
|
|
242
|
+
)
|
|
243
|
+
if not page.get("IsTruncated", False):
|
|
244
|
+
return tuple(result)
|
|
245
|
+
continuation = page.get("NextContinuationToken", "")
|
|
246
|
+
if not continuation:
|
|
247
|
+
raise RuntimeError(
|
|
248
|
+
"MinIO returned a truncated object page without a continuation token"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _client(*, settings: MinIOSettings) -> _S3Client:
|
|
253
|
+
"""Construct the path-style S3 client supported by local MinIO."""
|
|
254
|
+
return cast(
|
|
255
|
+
"_S3Client",
|
|
256
|
+
boto3.client(
|
|
257
|
+
"s3",
|
|
258
|
+
endpoint_url=settings.endpoint_url,
|
|
259
|
+
aws_access_key_id=settings.access_key.get_secret_value(),
|
|
260
|
+
aws_secret_access_key=settings.secret_key.get_secret_value(),
|
|
261
|
+
region_name=settings.region,
|
|
262
|
+
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _validated_key(*, key: ObjectKey) -> str:
|
|
268
|
+
"""Reject absolute and parent-traversal keys for local/S3 parity."""
|
|
269
|
+
parts = key.root.split("/")
|
|
270
|
+
if key.root.startswith("/") or ".." in parts:
|
|
271
|
+
raise ObjectKeyEscapesRootError(
|
|
272
|
+
f"object key {key.root!r} escapes the store root"
|
|
273
|
+
)
|
|
274
|
+
return key.root
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _error_code(*, error: ClientError) -> str:
|
|
278
|
+
"""Return the provider's stable error code without trimming the exception."""
|
|
279
|
+
return str(error.response.get("Error", {}).get("Code", ""))
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Local-directory mount publisher: the four D51 read-only views (e0 §5).
|
|
2
|
+
|
|
3
|
+
Agents read the memory on their filesystem: the **corpus filesystem** they
|
|
4
|
+
browse first, the **artifacts** they drill into from a stub, the **raw**
|
|
5
|
+
originals — deliberately *off the navigation path* — and the Plane-K
|
|
6
|
+
checkout. Every view is read-only; writes always go through the pipeline
|
|
7
|
+
and Postgres stays the authority.
|
|
8
|
+
|
|
9
|
+
The self-host tier materializes what the cloud tier gets from a bucket
|
|
10
|
+
mount, and implements D51's three guardrails here rather than assuming
|
|
11
|
+
them of infrastructure:
|
|
12
|
+
|
|
13
|
+
1. **Raw is off-path.** Nothing in the corpus tree links into it; reaching
|
|
14
|
+
an original means following an explicit `raw_uri` from a stub or from
|
|
15
|
+
`document.md` frontmatter — a deliberate act, never a browse default.
|
|
16
|
+
2. **Data-access audit logging is mandatory.** The audit property came
|
|
17
|
+
from logging, not from keeping raw unmounted (a mount read is still a
|
|
18
|
+
read), so originals are readable only through `AuditedRawReader`, which
|
|
19
|
+
refuses an unattributed request rather than logging a blank.
|
|
20
|
+
3. **Storage class routes by mime.** Media a multimodal harness actually
|
|
21
|
+
reads stays hot; text/office originals kept only for audit and
|
|
22
|
+
re-conversion go cold — this kills the grep-the-archive cost bug at the
|
|
23
|
+
source. Per-deployment config, like the D38 converter router.
|
|
24
|
+
|
|
25
|
+
The corpus view always serves the snapshot the registry marks latest, and
|
|
26
|
+
swaps whole trees atomically, so a browsing agent never walks a half-built
|
|
27
|
+
tree.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from datetime import UTC
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
import shutil
|
|
36
|
+
from typing import Final
|
|
37
|
+
from typing import Protocol
|
|
38
|
+
from uuid import UUID
|
|
39
|
+
from uuid import uuid4
|
|
40
|
+
|
|
41
|
+
from rememberstack.core import storage_class_for
|
|
42
|
+
from rememberstack.model import ObjectKey
|
|
43
|
+
from rememberstack.model import PublishedMounts
|
|
44
|
+
from rememberstack.ports.object_store import ObjectStorePort
|
|
45
|
+
from rememberstack.spine.projection import ProjectionCatalog
|
|
46
|
+
|
|
47
|
+
EMPTY_CORPUS_NOTE: Final = (
|
|
48
|
+
"# Corpus filesystem\n\n"
|
|
49
|
+
"> No P3 snapshot has been published yet. Run the corpus-filesystem\n"
|
|
50
|
+
"> builder; until then use the query API, which needs no projection.\n"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RawAccessDenied(Exception):
|
|
55
|
+
"""An unattributed raw read was refused (originals are audited)."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MountAdmission(Protocol):
|
|
59
|
+
"""The composition-owned barrier checked before publishing serving paths."""
|
|
60
|
+
|
|
61
|
+
def assert_available(self, *, deployment_id: UUID) -> None:
|
|
62
|
+
"""Raise while D74 keeps the deployment fail-closed."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LocalMountPublisher:
|
|
67
|
+
"""Publish the P3, artifact, raw, and Plane-K views as local trees."""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
root: Path,
|
|
73
|
+
catalog: ProjectionCatalog | None = None,
|
|
74
|
+
corpusfs_store: ObjectStorePort | None = None,
|
|
75
|
+
artifacts_root: Path | None = None,
|
|
76
|
+
raw_root: Path | None = None,
|
|
77
|
+
knowledge_root: Path | None = None,
|
|
78
|
+
admission: MountAdmission,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Bind the publisher to its mount root, the P3 source, and the stores.
|
|
81
|
+
|
|
82
|
+
The artifact/raw/knowledge views point at the REAL store roots when
|
|
83
|
+
given (Codex review: an empty directory is not a usable mount);
|
|
84
|
+
without them the publisher provisions empty view roots — the
|
|
85
|
+
Phase-0 shape, still useful before any store exists.
|
|
86
|
+
"""
|
|
87
|
+
self._root = root.resolve()
|
|
88
|
+
self._catalog = catalog
|
|
89
|
+
self._corpusfs_store = corpusfs_store
|
|
90
|
+
self._artifacts_root = artifacts_root
|
|
91
|
+
self._raw_root = raw_root
|
|
92
|
+
self._knowledge_root = knowledge_root
|
|
93
|
+
self._admission = admission
|
|
94
|
+
|
|
95
|
+
def publish(self, *, deployment_id: UUID) -> PublishedMounts:
|
|
96
|
+
"""Publish and return the exact four read-only deployment views."""
|
|
97
|
+
self._admission.assert_available(deployment_id=deployment_id)
|
|
98
|
+
base = self._root / str(deployment_id)
|
|
99
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
corpus = base / "p3"
|
|
101
|
+
self._materialize_corpus(deployment_id=deployment_id, link=corpus)
|
|
102
|
+
return PublishedMounts(
|
|
103
|
+
deployment_id=deployment_id,
|
|
104
|
+
p3=str(corpus),
|
|
105
|
+
artifacts=str(
|
|
106
|
+
self._view(base=base, name="artifacts", real=self._artifacts_root)
|
|
107
|
+
),
|
|
108
|
+
# off the navigation path (D51): the tree never promotes raw —
|
|
109
|
+
# stubs carry an explicit pointer, and reads go through
|
|
110
|
+
# AuditedRawReader, which is the only audited path on this tier
|
|
111
|
+
raw=str(self._view(base=base, name="raw", real=self._raw_root)),
|
|
112
|
+
knowledge=str(
|
|
113
|
+
self._view(base=base, name="knowledge", real=self._knowledge_root)
|
|
114
|
+
),
|
|
115
|
+
read_only=True,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _view(self, *, base: Path, name: str, real: Path | None) -> Path:
|
|
119
|
+
"""One view locator: the real store root when known, else an empty dir."""
|
|
120
|
+
if real is not None:
|
|
121
|
+
real.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
return real.resolve()
|
|
123
|
+
placeholder = base / name
|
|
124
|
+
placeholder.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
return placeholder
|
|
126
|
+
|
|
127
|
+
def _materialize_corpus(self, *, deployment_id: UUID, link: Path) -> None:
|
|
128
|
+
"""Serve the LATEST PUBLISHED snapshot behind an ATOMIC pointer.
|
|
129
|
+
|
|
130
|
+
The mount path is a symlink to a versioned directory, and the swap
|
|
131
|
+
replaces that symlink with `os.replace` — atomic on POSIX. The
|
|
132
|
+
previous "rmtree then rename" left a window where the mount path
|
|
133
|
+
did not exist at all, so a reader could hit ENOENT mid-swap and two
|
|
134
|
+
publishers could delete each other's staging (Codex review).
|
|
135
|
+
"""
|
|
136
|
+
if self._catalog is None or self._corpusfs_store is None:
|
|
137
|
+
link.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
return
|
|
139
|
+
latest = self._catalog.latest_snapshot(
|
|
140
|
+
deployment_id=deployment_id, plane="P3_corpusfs"
|
|
141
|
+
)
|
|
142
|
+
if latest is None:
|
|
143
|
+
empty = link.parent / "p3-empty"
|
|
144
|
+
empty.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
(empty / "llms.txt").write_text(EMPTY_CORPUS_NOTE, encoding="utf-8")
|
|
146
|
+
_point(link=link, target=empty)
|
|
147
|
+
return
|
|
148
|
+
version = str(latest["version"])
|
|
149
|
+
served = link.parent / f"p3-{version}"
|
|
150
|
+
if not (served / ".snapshot-version").exists():
|
|
151
|
+
prefix = str(latest["gcs_uri"])
|
|
152
|
+
manifest = json.loads(
|
|
153
|
+
self._corpusfs_store.read_bytes(
|
|
154
|
+
key=ObjectKey(f"{prefix}/MANIFEST.json")
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
# a unique staging dir per publisher: concurrent publishes of
|
|
158
|
+
# the same version never delete each other's work
|
|
159
|
+
staging = link.parent / f".staging-{version}-{uuid4().hex[:8]}"
|
|
160
|
+
for relative in manifest["files"]:
|
|
161
|
+
destination = staging / relative
|
|
162
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
destination.write_bytes(
|
|
164
|
+
self._corpusfs_store.read_bytes(
|
|
165
|
+
key=ObjectKey(f"{prefix}/{relative}")
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
(staging / ".snapshot-version").write_text(version, encoding="utf-8")
|
|
169
|
+
try:
|
|
170
|
+
staging.rename(served) # atomic: the version dir appears whole
|
|
171
|
+
except OSError: # another publisher won the race — theirs is fine
|
|
172
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
173
|
+
_point(link=link, target=served)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class AuditedRawReader:
|
|
177
|
+
"""The ONLY way to read an original — because reads must be logged.
|
|
178
|
+
|
|
179
|
+
D51's audit property comes from logging, not from keeping raw
|
|
180
|
+
unmounted: a mount read is still a read. So raw access is offered
|
|
181
|
+
exclusively through this reader, which records the accessor and the
|
|
182
|
+
stated purpose before returning bytes and refuses an unattributed
|
|
183
|
+
request outright rather than logging a blank.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(self, *, raw_store: ObjectStorePort, audit_log: Path) -> None:
|
|
187
|
+
"""Bind the reader to the raw store and its append-only audit log."""
|
|
188
|
+
self._raw_store = raw_store
|
|
189
|
+
self._audit_log = audit_log
|
|
190
|
+
|
|
191
|
+
def read(
|
|
192
|
+
self, *, deployment_id: UUID, raw_uri: str, accessor: str, purpose: str
|
|
193
|
+
) -> bytes:
|
|
194
|
+
"""Read one original, recording who read it and why."""
|
|
195
|
+
if not accessor.strip() or not purpose.strip():
|
|
196
|
+
raise RawAccessDenied(
|
|
197
|
+
"raw access requires an accessor and a stated purpose:"
|
|
198
|
+
" originals are audited, never anonymously readable"
|
|
199
|
+
)
|
|
200
|
+
content = self._raw_store.read_bytes(key=ObjectKey(raw_uri))
|
|
201
|
+
self._append(
|
|
202
|
+
{
|
|
203
|
+
"at": datetime.now(tz=UTC).isoformat(),
|
|
204
|
+
"deployment_id": str(deployment_id),
|
|
205
|
+
"raw_uri": raw_uri,
|
|
206
|
+
"accessor": accessor,
|
|
207
|
+
"purpose": purpose,
|
|
208
|
+
"bytes": len(content),
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
return content
|
|
212
|
+
|
|
213
|
+
def entries(self) -> tuple[dict[str, object], ...]:
|
|
214
|
+
"""The audit trail, oldest first (the operator's read surface)."""
|
|
215
|
+
if not self._audit_log.exists():
|
|
216
|
+
return ()
|
|
217
|
+
return tuple(
|
|
218
|
+
json.loads(line)
|
|
219
|
+
for line in self._audit_log.read_text(encoding="utf-8").splitlines()
|
|
220
|
+
if line.strip()
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def _append(self, entry: dict[str, object]) -> None:
|
|
224
|
+
"""Append one audit record (the log is append-only by construction)."""
|
|
225
|
+
self._audit_log.parent.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
with self._audit_log.open("a", encoding="utf-8") as handle:
|
|
227
|
+
handle.write(json.dumps(entry) + "\n")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _point(*, link: Path, target: Path) -> None:
|
|
231
|
+
"""Atomically point the mount path at a versioned directory.
|
|
232
|
+
|
|
233
|
+
A symlink swapped with `os.replace` is atomic on POSIX: a reader either
|
|
234
|
+
sees the old snapshot or the new one, never a missing path.
|
|
235
|
+
"""
|
|
236
|
+
staging_link = link.with_name(f".{link.name}-{uuid4().hex[:8]}")
|
|
237
|
+
staging_link.symlink_to(target, target_is_directory=True)
|
|
238
|
+
if link.exists() and not link.is_symlink():
|
|
239
|
+
shutil.rmtree(link) # a legacy real directory: replaced once
|
|
240
|
+
os.replace(staging_link, link)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
__all__ = (
|
|
244
|
+
"AuditedRawReader",
|
|
245
|
+
"EMPTY_CORPUS_NOTE",
|
|
246
|
+
"LocalMountPublisher",
|
|
247
|
+
"RawAccessDenied",
|
|
248
|
+
"storage_class_for", # re-exported: the adapter applies this policy
|
|
249
|
+
)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Local-filesystem object store adapter: immutable bytes under one root (D61/D62)."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from rememberstack.model import ObjectAlreadyExistsError
|
|
6
|
+
from rememberstack.model import ObjectKey
|
|
7
|
+
from rememberstack.model import ObjectKeyEscapesRootError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LocalFSObjectStore:
|
|
11
|
+
"""The self-host object store: one directory tree of immutable objects."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, *, root: Path) -> None:
|
|
14
|
+
"""Bind the store to its root directory, creating it if absent."""
|
|
15
|
+
self._root = root.resolve()
|
|
16
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
|
|
18
|
+
def read_bytes(self, *, key: ObjectKey) -> bytes:
|
|
19
|
+
"""Read all bytes stored under an existing object key."""
|
|
20
|
+
return self._path_for(key=key).read_bytes()
|
|
21
|
+
|
|
22
|
+
def write_bytes(
|
|
23
|
+
self, *, key: ObjectKey, content: bytes, storage_class: str | None = None
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Create immutable bytes, failing rather than replacing an occupied key.
|
|
26
|
+
|
|
27
|
+
A local filesystem has no storage classes, so the D51 routing
|
|
28
|
+
decision is RECORDED beside the object instead of dropped — the
|
|
29
|
+
cloud adapter turns the same value into a real class, and either
|
|
30
|
+
way an operator can see what each original was routed to.
|
|
31
|
+
"""
|
|
32
|
+
path = self._path_for(key=key)
|
|
33
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
try:
|
|
35
|
+
with path.open(mode="xb") as handle:
|
|
36
|
+
handle.write(content)
|
|
37
|
+
except FileExistsError as err:
|
|
38
|
+
raise ObjectAlreadyExistsError(
|
|
39
|
+
f"object key {key.root!r} is already occupied; objects are immutable"
|
|
40
|
+
) from err
|
|
41
|
+
if storage_class is not None:
|
|
42
|
+
path.with_name(f"{path.name}.storage-class").write_text(
|
|
43
|
+
storage_class, encoding="utf-8"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def storage_class_of(self, *, key: ObjectKey) -> str | None:
|
|
47
|
+
"""The class one object was routed to, when the writer declared it."""
|
|
48
|
+
marker = self._path_for(key=key).with_suffix("")
|
|
49
|
+
marker = self._path_for(key=key)
|
|
50
|
+
marker = marker.with_name(f"{marker.name}.storage-class")
|
|
51
|
+
return marker.read_text(encoding="utf-8") if marker.exists() else None
|
|
52
|
+
|
|
53
|
+
def purge_objects(
|
|
54
|
+
self, *, keys: tuple[ObjectKey, ...], prefixes: tuple[ObjectKey, ...]
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Idempotently erase exact keys, storage markers, and prefix matches."""
|
|
57
|
+
exact_paths = tuple(self._path_for(key=key) for key in keys)
|
|
58
|
+
normalized_prefixes = tuple(
|
|
59
|
+
self._path_for(key=prefix).relative_to(self._root).as_posix()
|
|
60
|
+
for prefix in prefixes
|
|
61
|
+
)
|
|
62
|
+
for path in exact_paths:
|
|
63
|
+
path.unlink(missing_ok=True)
|
|
64
|
+
path.with_name(f"{path.name}.storage-class").unlink(missing_ok=True)
|
|
65
|
+
if normalized_prefixes:
|
|
66
|
+
for path in tuple(self._root.rglob("*")):
|
|
67
|
+
if not (path.is_file() or path.is_symlink()):
|
|
68
|
+
continue
|
|
69
|
+
relative = path.relative_to(self._root).as_posix()
|
|
70
|
+
if any(
|
|
71
|
+
relative == prefix.rstrip("/")
|
|
72
|
+
or relative.startswith(f"{prefix.rstrip('/')}/")
|
|
73
|
+
for prefix in normalized_prefixes
|
|
74
|
+
):
|
|
75
|
+
path.unlink(missing_ok=True)
|
|
76
|
+
self._remove_empty_directories()
|
|
77
|
+
|
|
78
|
+
def verify_objects_purged(
|
|
79
|
+
self, *, keys: tuple[ObjectKey, ...], prefixes: tuple[ObjectKey, ...]
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Prove exact keys and prefix-boundary descendants are absent."""
|
|
82
|
+
remaining = [
|
|
83
|
+
key.root
|
|
84
|
+
for key in keys
|
|
85
|
+
if self._path_for(key=key).exists()
|
|
86
|
+
or self._path_for(key=key)
|
|
87
|
+
.with_name(f"{self._path_for(key=key).name}.storage-class")
|
|
88
|
+
.exists()
|
|
89
|
+
]
|
|
90
|
+
normalized_prefixes = tuple(
|
|
91
|
+
self._path_for(key=prefix).relative_to(self._root).as_posix().rstrip("/")
|
|
92
|
+
for prefix in prefixes
|
|
93
|
+
)
|
|
94
|
+
for path in self._root.rglob("*"):
|
|
95
|
+
if not (path.is_file() or path.is_symlink()):
|
|
96
|
+
continue
|
|
97
|
+
relative = path.relative_to(self._root).as_posix()
|
|
98
|
+
if any(
|
|
99
|
+
relative == prefix or relative.startswith(f"{prefix}/")
|
|
100
|
+
for prefix in normalized_prefixes
|
|
101
|
+
):
|
|
102
|
+
remaining.append(relative)
|
|
103
|
+
if remaining:
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
f"object purge verification found: {sorted(remaining)!r}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def _path_for(self, *, key: ObjectKey) -> Path:
|
|
109
|
+
"""Resolve a key to a path strictly inside the root (no traversal)."""
|
|
110
|
+
candidate = (self._root / key.root).resolve()
|
|
111
|
+
if not candidate.is_relative_to(self._root):
|
|
112
|
+
raise ObjectKeyEscapesRootError(
|
|
113
|
+
f"object key {key.root!r} escapes the store root"
|
|
114
|
+
)
|
|
115
|
+
return candidate
|
|
116
|
+
|
|
117
|
+
def _remove_empty_directories(self) -> None:
|
|
118
|
+
"""Prune empty object-key parents without ever removing the store root."""
|
|
119
|
+
directories = sorted(
|
|
120
|
+
(
|
|
121
|
+
path
|
|
122
|
+
for path in self._root.rglob("*")
|
|
123
|
+
if path.is_dir() and not path.is_symlink()
|
|
124
|
+
),
|
|
125
|
+
key=lambda path: len(path.parts),
|
|
126
|
+
reverse=True,
|
|
127
|
+
)
|
|
128
|
+
for directory in directories:
|
|
129
|
+
if not any(directory.iterdir()):
|
|
130
|
+
directory.rmdir()
|