This commit is contained in:
1 parent
4db3f45164
commit
d25c04cdd2
175 files changed
+56243
-15
No files matched your search
@@ -0,0 +1 @@
|
||||
"""GEWU use cases."""
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
||||
"""Human accounts and account-owned agent credential contracts."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from gewu.application.admin import CredentialSummary
|
||||
from gewu.application.identity import BrowserSession, Principal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccountSummary:
|
||||
username: str
|
||||
principal_id: str
|
||||
role: str
|
||||
project_ids: list[str]
|
||||
active: bool
|
||||
|
||||
|
||||
class AccountIdentity(Protocol):
|
||||
def create_account(
|
||||
self,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
role: str,
|
||||
project_ids: tuple[str, ...],
|
||||
actor: str,
|
||||
) -> AccountSummary: ...
|
||||
|
||||
def list_accounts(self, *, limit: int, offset: int) -> list[AccountSummary]: ...
|
||||
|
||||
def login_account(self, username: str, password: str) -> BrowserSession | None: ...
|
||||
|
||||
def change_account_password(
|
||||
self, principal: Principal, current_password: str, new_password: str
|
||||
) -> bool: ...
|
||||
|
||||
def update_account(
|
||||
self, username: str, *, role: str, project_ids: tuple[str, ...], actor: Principal
|
||||
) -> AccountSummary | None: ...
|
||||
|
||||
def disable_account(self, username: str, *, actor: Principal) -> bool: ...
|
||||
|
||||
def list_agent_credentials(
|
||||
self, principal: Principal, *, limit: int, offset: int
|
||||
) -> list[CredentialSummary]: ...
|
||||
|
||||
def create_agent_credential(
|
||||
self, principal: Principal, *, name: str, project_ids: tuple[str, ...]
|
||||
) -> tuple[str, str]: ...
|
||||
|
||||
def revoke_agent_credential(self, principal: Principal, credential_id: str) -> bool: ...
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Secret-free administration records and credential management port."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CredentialSummary:
|
||||
credential_id: str
|
||||
principal_id: str
|
||||
roles: list[str]
|
||||
project_ids: list[str]
|
||||
revoked: bool
|
||||
owner_principal_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdminEvent:
|
||||
event_id: int
|
||||
occurred_at: str
|
||||
actor: str
|
||||
action: str
|
||||
credential_id: str
|
||||
|
||||
|
||||
class CredentialAdministration(Protocol):
|
||||
def list_credentials(self, *, limit: int, offset: int) -> list[CredentialSummary]: ...
|
||||
|
||||
def list_admin_events(self, *, limit: int) -> list[AdminEvent]: ...
|
||||
|
||||
def provision(
|
||||
self,
|
||||
*,
|
||||
credential_id: str,
|
||||
principal_id: str,
|
||||
roles: tuple[str, ...],
|
||||
project_ids: tuple[str, ...] = (),
|
||||
actor: str | None = None,
|
||||
) -> str: ...
|
||||
|
||||
def update_credential(
|
||||
self, credential_id: str, *, role: str, project_ids: tuple[str, ...], actor: str
|
||||
) -> CredentialSummary | None: ...
|
||||
|
||||
def revoke(self, credential_id: str, *, actor: str | None = None) -> bool: ...
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Immutable validation snapshot passed into proposal acceptance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from gewu.domain.contracts import (
|
||||
AutoAcceptPolicy,
|
||||
ExtractionRecord,
|
||||
KnowledgeNode,
|
||||
ProjectRecord,
|
||||
SourceRevisionRecord,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ValidationContext:
|
||||
projects: Mapping[str, ProjectRecord]
|
||||
policies: Mapping[str, AutoAcceptPolicy]
|
||||
sources: Mapping[str, SourceRevisionRecord]
|
||||
extractions: Mapping[str, ExtractionRecord]
|
||||
existing_nodes: Mapping[str, KnowledgeNode]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Authenticated, secret-free service compatibility diagnostics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, cast
|
||||
|
||||
from gewu.application.identity import Principal
|
||||
from gewu.application.uploads import ALLOWED_MEDIA_TYPES, CHUNK_BYTES, MAX_ARTIFACT_BYTES
|
||||
from gewu.domain.contracts import Scope, ServiceDiagnostics
|
||||
from gewu.domain.operations import Transport, catalog_digest, operations_for
|
||||
from gewu.version import SERVICE_VERSION
|
||||
|
||||
|
||||
def build_service_diagnostics(
|
||||
*,
|
||||
principal: Principal,
|
||||
transport: Literal["http", "mcp"],
|
||||
authorized_projects: tuple[str, ...],
|
||||
available_operations: frozenset[str],
|
||||
index_status: tuple[str, str | None, str, int | None],
|
||||
projects: tuple[str, ...] | None = None,
|
||||
include_shared: bool = False,
|
||||
) -> ServiceDiagnostics:
|
||||
"""Build caller-specific diagnostics without exposing credential identifiers."""
|
||||
|
||||
visible_projects = tuple(sorted(set(authorized_projects)))
|
||||
if projects is None:
|
||||
if include_shared:
|
||||
raise ValueError("Select at least one project before including shared knowledge")
|
||||
selected_scope = None
|
||||
else:
|
||||
if not projects or len(set(projects)) != len(projects):
|
||||
raise ValueError("Selected projects must be non-empty and unique")
|
||||
if any(project not in visible_projects for project in projects):
|
||||
raise PermissionError("Selected scope includes an unreadable project")
|
||||
if include_shared and not principal.can_read_project("shared"):
|
||||
raise PermissionError("Credential cannot read shared knowledge")
|
||||
selected_scope = Scope(projects=projects, include_shared=include_shared)
|
||||
|
||||
capabilities: set[str] = {"service:diagnostics"}
|
||||
if "admin" in principal.roles and "agent" not in principal.roles:
|
||||
capabilities.update({"project:admin", "credential:admin"})
|
||||
if principal.roles.intersection({"admin", "editor", "viewer", "agent"}):
|
||||
capabilities.add("knowledge:read")
|
||||
if principal.auth_method == "bearer" and "agent" in principal.roles:
|
||||
capabilities.update(
|
||||
{
|
||||
"proposal:submit",
|
||||
"proposal:evaluate",
|
||||
"proposal:read",
|
||||
"proposal:withdraw",
|
||||
"run:read",
|
||||
"run:write",
|
||||
}
|
||||
)
|
||||
if principal.roles.intersection({"admin", "editor", "viewer"}):
|
||||
capabilities.add("identity:session")
|
||||
|
||||
catalog_transport = Transport(transport)
|
||||
authorized_operations = tuple(
|
||||
sorted(
|
||||
operation.name
|
||||
for operation in operations_for(catalog_transport)
|
||||
if operation.name in available_operations and operation.capability in capabilities
|
||||
)
|
||||
)
|
||||
canonical, index_revision, index_state, index_schema_version = index_status
|
||||
return ServiceDiagnostics(
|
||||
service_version=SERVICE_VERSION,
|
||||
catalog_sha256=catalog_digest(),
|
||||
transport=transport,
|
||||
authentication=cast(Literal["bearer", "browser_session"], principal.auth_method),
|
||||
roles=tuple(sorted(principal.roles)),
|
||||
authorized_projects=visible_projects,
|
||||
selected_scope=selected_scope,
|
||||
capabilities=tuple(sorted(capabilities)),
|
||||
authorized_operations=authorized_operations,
|
||||
max_upload_bytes=MAX_ARTIFACT_BYTES,
|
||||
upload_chunk_bytes=CHUNK_BYTES,
|
||||
supported_media_types=tuple(sorted(ALLOWED_MEDIA_TYPES)),
|
||||
canonical_revision=canonical,
|
||||
index_state=cast(
|
||||
Literal["current", "lagging", "not_built", "not_configured", "unavailable"],
|
||||
index_state,
|
||||
),
|
||||
index_revision=index_revision,
|
||||
index_schema_version=index_schema_version,
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Authenticated principals and capability checks shared by transports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Principal:
|
||||
principal_id: str
|
||||
credential_id: str
|
||||
roles: frozenset[str]
|
||||
project_ids: frozenset[str]
|
||||
auth_method: str = "bearer"
|
||||
|
||||
def can_read_project(self, project_id: str) -> bool:
|
||||
return "admin" in self.roles or project_id in self.project_ids
|
||||
|
||||
def can_submit_project(self, project_id: str) -> bool:
|
||||
return (
|
||||
self.auth_method == "bearer"
|
||||
and "agent" in self.roles
|
||||
and project_id in self.project_ids
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BrowserSession:
|
||||
session_token: str
|
||||
csrf_token: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerSession:
|
||||
session_token: str
|
||||
expires_at: datetime
|
||||
challenge_id: str
|
||||
|
||||
|
||||
class IdentityProvider(Protocol):
|
||||
def authenticate(self, bearer_token: str) -> Principal | None: ...
|
||||
|
||||
def create_browser_session(self, bearer_token: str) -> BrowserSession: ...
|
||||
|
||||
def authenticate_browser_session(self, session_token: str) -> Principal | None: ...
|
||||
|
||||
def restore_browser_session(self, session_token: str) -> BrowserSession | None: ...
|
||||
|
||||
def browser_csrf_valid(self, session_token: str, csrf_token: str) -> bool: ...
|
||||
|
||||
def revoke_browser_session(self, session_token: str) -> bool: ...
|
||||
|
||||
|
||||
class OwnerIdentityProvider(Protocol):
|
||||
"""Owner-only identity surface, separate from agent/browser authentication."""
|
||||
|
||||
@property
|
||||
def owner_authentication_enabled(self) -> bool: ...
|
||||
|
||||
def authenticate_owner(
|
||||
self,
|
||||
*,
|
||||
principal_id: str,
|
||||
password: str,
|
||||
totp_code: str,
|
||||
challenge_id: str,
|
||||
audience: str,
|
||||
) -> OwnerSession | None: ...
|
||||
|
||||
def authenticate_owner_session(
|
||||
self, session_token: str, *, challenge_id: str, audience: str
|
||||
) -> Principal | None: ...
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Filesystem checks shared by private local persistence adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def reject_symlink_components(path: Path) -> None:
|
||||
"""Reject a path whose existing component redirects persistence elsewhere."""
|
||||
|
||||
for component in (path, *path.parents):
|
||||
if component.is_symlink():
|
||||
raise ValueError("Private data paths must not traverse symbolic links")
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Application-owned ports implemented by replaceable persistence adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Protocol
|
||||
|
||||
from gewu.domain.contracts import AutoAcceptPolicy, ProjectRecord
|
||||
|
||||
|
||||
class CanonicalRepositoryError(RuntimeError):
|
||||
"""A committed-record operation failed without exposing backend details."""
|
||||
|
||||
|
||||
class CanonicalConflictError(CanonicalRepositoryError):
|
||||
"""Expected-head publication lost its compare-and-swap race."""
|
||||
|
||||
|
||||
_CANONICAL_PATH = re.compile(r"^[A-Za-z0-9._/-]+$")
|
||||
|
||||
|
||||
def validate_canonical_path(path: str) -> None:
|
||||
"""Enforce portable, relative POSIX paths for all repository implementations."""
|
||||
|
||||
pure = PurePosixPath(path)
|
||||
if (
|
||||
not path
|
||||
or path.startswith("/")
|
||||
or "\\" in path
|
||||
or not _CANONICAL_PATH.fullmatch(path)
|
||||
or pure.as_posix() != path
|
||||
or any(part in {"", ".", ".."} for part in pure.parts)
|
||||
):
|
||||
raise CanonicalRepositoryError("Canonical record path is invalid")
|
||||
|
||||
|
||||
class CanonicalRepository(Protocol):
|
||||
"""Committed reads and expected-head atomic publication.
|
||||
|
||||
``history`` returns newest-first revisions that changed the requested path;
|
||||
``introducing_commit`` returns the oldest revision in that path history.
|
||||
Paths use ``validate_canonical_path``. Implementations raise the port-owned
|
||||
errors above, never backend exceptions.
|
||||
"""
|
||||
|
||||
def head(self) -> str: ...
|
||||
|
||||
def read_file(self, revision: str, path: str) -> bytes | None: ...
|
||||
|
||||
def list_files(self, revision: str, prefix: str) -> tuple[str, ...]: ...
|
||||
|
||||
def publish_files(
|
||||
self,
|
||||
*,
|
||||
expected_head: str,
|
||||
files: Mapping[str, bytes],
|
||||
message: str,
|
||||
index_directory: Path,
|
||||
) -> str: ...
|
||||
|
||||
def introducing_commit(self, revision: str, path: str) -> str | None: ...
|
||||
|
||||
def history(self, revision: str, path: str, limit: int) -> tuple[str, ...]: ...
|
||||
|
||||
|
||||
class ProjectRegistry(Protocol):
|
||||
"""Versioned project and policy registry with serialized lifecycle writes."""
|
||||
|
||||
def records(self) -> tuple[ProjectRecord, ...]: ...
|
||||
|
||||
def create(self, project: ProjectRecord, policy: AutoAcceptPolicy) -> ProjectRecord: ...
|
||||
|
||||
def update_content(self, project_id: str, *, description: str, north_star: str, key_questions: tuple[str, ...]) -> ProjectRecord: ...
|
||||
|
||||
def archive(self, project_id: str) -> ProjectRecord: ...
|
||||
|
||||
|
||||
class EmbeddingProvider(Protocol):
|
||||
"""Replaceable, normalized text-vector provider for derived retrieval indexes."""
|
||||
|
||||
@property
|
||||
def dimensions(self) -> int: ...
|
||||
|
||||
@property
|
||||
def model_id(self) -> str: ...
|
||||
|
||||
@property
|
||||
def artifact_sha256(self) -> str: ...
|
||||
|
||||
def embed_queries(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]: ...
|
||||
|
||||
def embed_passages(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VectorHit:
|
||||
node_id: str
|
||||
project_id: str
|
||||
distance: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VectorGeneration:
|
||||
generation_id: str
|
||||
revision: str
|
||||
model_id: str
|
||||
artifact_sha256: str
|
||||
content_profile: str
|
||||
dimensions: int
|
||||
node_count: int
|
||||
|
||||
|
||||
class VectorIndex(Protocol):
|
||||
"""Scoped vector candidate lookup over an activated immutable generation."""
|
||||
|
||||
def search(
|
||||
self,
|
||||
*,
|
||||
query: tuple[float, ...],
|
||||
project_ids: tuple[str, ...],
|
||||
include_shared: bool,
|
||||
limit: int,
|
||||
expected_generation_id: str | None = None,
|
||||
) -> tuple[VectorHit, ...]: ...
|
||||
|
||||
def active_generation(self) -> VectorGeneration | None: ...
|
||||
|
||||
def generation(self, generation_id: str) -> VectorGeneration | None: ...
|
||||
|
||||
def build_generation(
|
||||
self,
|
||||
*,
|
||||
generation_id: str,
|
||||
revision: str,
|
||||
model_id: str,
|
||||
artifact_sha256: str,
|
||||
content_profile: str,
|
||||
dimensions: int,
|
||||
vectors: Iterable[tuple[str, str, tuple[float, ...]]],
|
||||
) -> None: ...
|
||||
|
||||
def activate(self, generation_id: str, *, expected_active: str | None) -> str | None: ...
|
||||
|
||||
def active_generation_in_transaction(
|
||||
self, connection: sqlite3.Connection
|
||||
) -> VectorGeneration | None: ...
|
||||
|
||||
def generation_in_transaction(
|
||||
self, connection: sqlite3.Connection, generation_id: str
|
||||
) -> VectorGeneration | None: ...
|
||||
|
||||
def build_generation_in_transaction(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
generation_id: str,
|
||||
revision: str,
|
||||
model_id: str,
|
||||
artifact_sha256: str,
|
||||
content_profile: str,
|
||||
dimensions: int,
|
||||
vectors: Iterable[tuple[str, str, tuple[float, ...]]],
|
||||
) -> None: ...
|
||||
|
||||
def activate_in_transaction(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
generation_id: str,
|
||||
*,
|
||||
expected_active: str | None,
|
||||
) -> str | None: ...
|
||||
|
||||
def deactivate_in_transaction(self, connection: sqlite3.Connection) -> None: ...
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Bounded canonical reads for a single project node and its citations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
|
||||
import yaml
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from gewu.application.identity import Principal
|
||||
from gewu.application.ports import CanonicalRepository
|
||||
from gewu.domain.contracts import (
|
||||
BudgetProfile,
|
||||
EvidenceReference,
|
||||
GitCommitId,
|
||||
KnowledgeNode,
|
||||
ReadEnvelope,
|
||||
Scope,
|
||||
SourceRevisionRecord,
|
||||
)
|
||||
|
||||
|
||||
class ReadError(RuntimeError):
|
||||
def __init__(self, status_code: int, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def _key(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
_GIT_COMMIT_ID = TypeAdapter(GitCommitId)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CanonicalNodeReader:
|
||||
repository: CanonicalRepository
|
||||
|
||||
def get_node(
|
||||
self,
|
||||
project_id: str,
|
||||
node_id: str,
|
||||
*,
|
||||
principal: Principal,
|
||||
revision: str | None = None,
|
||||
) -> ReadEnvelope:
|
||||
if not principal.can_read_project(project_id):
|
||||
raise ReadError(403, "Credential cannot read this project")
|
||||
head = self.repository.head()
|
||||
path = f"projects/{_key(project_id)}/nodes/{_key(node_id)}.yaml"
|
||||
if revision is None:
|
||||
selected_revision = head
|
||||
else:
|
||||
try:
|
||||
selected_revision = _GIT_COMMIT_ID.validate_python(revision, strict=True)
|
||||
except ValidationError as error:
|
||||
raise ReadError(422, "Canonical node revision is invalid") from error
|
||||
if selected_revision not in self.repository.history(head, path, 20):
|
||||
raise ReadError(404, "Knowledge node revision was not found")
|
||||
raw = self.repository.read_file(selected_revision, path)
|
||||
if raw is None:
|
||||
raise ReadError(404, "Knowledge node was not found")
|
||||
try:
|
||||
value = yaml.safe_load(raw)
|
||||
node = KnowledgeNode.model_validate_json(
|
||||
json.dumps(value, ensure_ascii=False), strict=True
|
||||
)
|
||||
except (ValueError, yaml.YAMLError, ValidationError) as error:
|
||||
raise ReadError(503, "Canonical knowledge record failed validation") from error
|
||||
if node.node_id != node_id or node.project_id != project_id:
|
||||
raise ReadError(503, "Canonical record identity does not match its path")
|
||||
citations = tuple(evidence for claim in node.evidence_claims for evidence in claim.evidence)
|
||||
envelope = ReadEnvelope(
|
||||
request_id="read-" + secrets.token_hex(12),
|
||||
resolved_scope=Scope(
|
||||
projects=(project_id,), include_shared=principal.can_read_project("shared")
|
||||
),
|
||||
data=(node.model_dump(mode="json"),),
|
||||
citations=citations,
|
||||
canonical_revision=selected_revision,
|
||||
index_revision=None,
|
||||
retrieval_mode="canonical",
|
||||
budget_profile=BudgetProfile(tokenizer="none", encoding="raw-record-v1"),
|
||||
tokens_used=0,
|
||||
)
|
||||
if len(envelope.model_dump_json().encode("utf-8")) > 1_000_000:
|
||||
raise ReadError(413, "Canonical node exceeds the read response budget")
|
||||
return envelope
|
||||
|
||||
def get_history(
|
||||
self, project_id: str, node_id: str, *, principal: Principal, limit: int = 20
|
||||
) -> ReadEnvelope:
|
||||
if not 1 <= limit <= 20:
|
||||
raise ReadError(422, "History limit must be between one and twenty")
|
||||
if not principal.can_read_project(project_id):
|
||||
raise ReadError(403, "Credential cannot read this project")
|
||||
path = f"projects/{_key(project_id)}/nodes/{_key(node_id)}.yaml"
|
||||
head = self.repository.head()
|
||||
rows: list[dict[str, object]] = []
|
||||
citations: dict[str, EvidenceReference] = {}
|
||||
for revision in self.repository.history(head, path, limit):
|
||||
raw = self.repository.read_file(revision, path)
|
||||
if raw is None:
|
||||
continue
|
||||
node = _validated_record(
|
||||
raw, KnowledgeNode, "Canonical knowledge record failed validation"
|
||||
)
|
||||
if node.node_id != node_id or node.project_id != project_id:
|
||||
raise ReadError(503, "Canonical record identity does not match its path")
|
||||
rows.append({"revision": revision, "node": node.model_dump(mode="json")})
|
||||
for claim in node.evidence_claims:
|
||||
for evidence in claim.evidence:
|
||||
citations[evidence.model_dump_json()] = evidence
|
||||
return _envelope(
|
||||
request_id="history-" + secrets.token_hex(12),
|
||||
project_id=project_id,
|
||||
include_shared=principal.can_read_project("shared"),
|
||||
data=tuple(rows),
|
||||
citations=tuple(citations[key] for key in sorted(citations)),
|
||||
revision=head,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CanonicalSourceReader:
|
||||
repository: CanonicalRepository
|
||||
|
||||
def get_source(
|
||||
self,
|
||||
project_id: str,
|
||||
revision_id: str,
|
||||
*,
|
||||
principal: Principal,
|
||||
) -> ReadEnvelope:
|
||||
source, head = self.get_source_record(project_id, revision_id, principal=principal)
|
||||
return _envelope(
|
||||
request_id="source-" + secrets.token_hex(12),
|
||||
project_id=project_id,
|
||||
include_shared=principal.can_read_project("shared"),
|
||||
data=(source.model_dump(mode="json"),),
|
||||
citations=(),
|
||||
revision=head,
|
||||
)
|
||||
|
||||
def get_source_record(
|
||||
self,
|
||||
project_id: str,
|
||||
revision_id: str,
|
||||
*,
|
||||
principal: Principal,
|
||||
) -> tuple[SourceRevisionRecord, str]:
|
||||
if not principal.can_read_project(project_id):
|
||||
raise ReadError(403, "Credential cannot read this project")
|
||||
head = self.repository.head()
|
||||
source_key = _key(revision_id)
|
||||
source_paths = self.repository.list_files(head, "sources")
|
||||
if len(source_paths) > 100_000:
|
||||
raise ReadError(503, "Canonical source registry exceeds the read limit")
|
||||
matching_path = next(
|
||||
(path for path in source_paths if path.endswith(f"/{source_key}.yaml")), None
|
||||
)
|
||||
if matching_path is None:
|
||||
raise ReadError(404, "Source revision was not found within the requested project")
|
||||
raw = self.repository.read_file(head, matching_path)
|
||||
if raw is None:
|
||||
raise ReadError(404, "Source revision was not found within the requested project")
|
||||
source = _validated_record(
|
||||
raw, SourceRevisionRecord, "Canonical source record failed validation"
|
||||
)
|
||||
if (
|
||||
source.revision_id != revision_id
|
||||
or matching_path != f"sources/{_key(source.source_id)}/{_key(source.revision_id)}.yaml"
|
||||
):
|
||||
raise ReadError(503, "Canonical source identity does not match its path")
|
||||
if project_id not in source.ingested_for:
|
||||
raise ReadError(404, "Source revision was not found within the requested project")
|
||||
return source, head
|
||||
|
||||
|
||||
def _validated_record[RecordT: (KnowledgeNode, SourceRevisionRecord)](
|
||||
raw: bytes, record_type: type[RecordT], message: str
|
||||
) -> RecordT:
|
||||
if len(raw) > 1_048_576:
|
||||
raise ReadError(503, message)
|
||||
try:
|
||||
value = yaml.safe_load(raw)
|
||||
return record_type.model_validate_json(json.dumps(value, ensure_ascii=False), strict=True)
|
||||
except (ValueError, yaml.YAMLError, ValidationError) as error:
|
||||
raise ReadError(503, message) from error
|
||||
|
||||
|
||||
def _envelope(
|
||||
*,
|
||||
request_id: str,
|
||||
project_id: str,
|
||||
include_shared: bool,
|
||||
data: tuple[dict[str, object], ...],
|
||||
citations: tuple[EvidenceReference, ...],
|
||||
revision: str,
|
||||
) -> ReadEnvelope:
|
||||
envelope = ReadEnvelope(
|
||||
request_id=request_id,
|
||||
resolved_scope=Scope(projects=(project_id,), include_shared=include_shared),
|
||||
data=data,
|
||||
citations=citations,
|
||||
canonical_revision=revision,
|
||||
index_revision=None,
|
||||
retrieval_mode="canonical",
|
||||
budget_profile=BudgetProfile(tokenizer="none", encoding="raw-record-v1"),
|
||||
tokens_used=0,
|
||||
)
|
||||
if len(envelope.model_dump_json().encode("utf-8")) > 1_000_000:
|
||||
raise ReadError(413, "Canonical read exceeds the response budget")
|
||||
return envelope
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Local project/policy configuration and committed-record context provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import portalocker
|
||||
import yaml
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from gewu.application.context import ValidationContext
|
||||
from gewu.application.paths import reject_symlink_components
|
||||
from gewu.application.ports import CanonicalConflictError, CanonicalRepository
|
||||
from gewu.application.serialization import safe_yaml_record
|
||||
from gewu.domain.contracts import (
|
||||
AutoAcceptPolicy,
|
||||
ExtractionRecord,
|
||||
KnowledgeNode,
|
||||
ProjectRecord,
|
||||
ProjectStatus,
|
||||
SourceRevisionRecord,
|
||||
)
|
||||
from gewu.domain.validation import extraction_digest
|
||||
|
||||
MAX_CONTEXT_RECORDS = 20_000
|
||||
MAX_CONTEXT_RECORD_BYTES = 1_048_576
|
||||
MAX_PROJECT_REGISTRY_RESPONSE_BYTES = 1_048_576
|
||||
|
||||
|
||||
class RegistryError(RuntimeError):
|
||||
"""Configured or committed context is malformed; acceptance must stop."""
|
||||
|
||||
|
||||
class RegistryConflict(RegistryError):
|
||||
"""A registry write conflicts with current project or policy identity."""
|
||||
|
||||
|
||||
class RegistryNotFound(RegistryError):
|
||||
"""A requested project is not registered."""
|
||||
|
||||
|
||||
def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||
result: dict[str, object] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError("duplicate JSON key")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _records[RecordT: BaseModel](
|
||||
raw: bytes, field: str, record_type: type[RecordT]
|
||||
) -> tuple[RecordT, ...]:
|
||||
if len(raw) > MAX_CONTEXT_RECORD_BYTES:
|
||||
raise RegistryError("Registry configuration exceeds the supported size")
|
||||
try:
|
||||
value = json.loads(raw, object_pairs_hook=_unique_object)
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or set(value) != {"schema_version", "projects", "policies"}
|
||||
or value.get("schema_version") != 1
|
||||
):
|
||||
raise ValueError("registry schema version or fields are invalid")
|
||||
records = value[field]
|
||||
if not isinstance(records, list):
|
||||
raise ValueError("registry field must be a list")
|
||||
return tuple(
|
||||
record_type.model_validate_json(json.dumps(item, ensure_ascii=False), strict=True)
|
||||
for item in records
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError, ValidationError) as error:
|
||||
raise RegistryError("Registry configuration failed validation") from error
|
||||
|
||||
|
||||
def _index[RecordT: BaseModel](records: tuple[RecordT, ...], field: str) -> dict[str, RecordT]:
|
||||
result: dict[str, RecordT] = {}
|
||||
for record in records:
|
||||
key = getattr(record, field)
|
||||
if key in result:
|
||||
raise RegistryError("Registry contains duplicate stable IDs")
|
||||
result[key] = record
|
||||
return result
|
||||
|
||||
|
||||
def _canonical_records[RecordT: BaseModel](
|
||||
repository: CanonicalRepository,
|
||||
revision: str,
|
||||
prefix: str,
|
||||
record_type: type[RecordT],
|
||||
id_field: str,
|
||||
) -> dict[str, RecordT]:
|
||||
records: dict[str, RecordT] = {}
|
||||
for record_path in repository.list_files(revision, prefix):
|
||||
if not record_path.endswith(".yaml"):
|
||||
continue
|
||||
if prefix == "projects" and "/nodes/" not in record_path:
|
||||
continue
|
||||
raw = repository.read_file(revision, record_path)
|
||||
if raw is None or len(raw) > MAX_CONTEXT_RECORD_BYTES:
|
||||
raise RegistryError("Canonical context record is unavailable or too large")
|
||||
record = safe_yaml_record(raw, record_type)
|
||||
record_id = getattr(record, id_field)
|
||||
if record_id in records:
|
||||
raise RegistryError("Canonical repository contains duplicate stable IDs")
|
||||
records[record_id] = record
|
||||
if len(records) > MAX_CONTEXT_RECORDS:
|
||||
raise RegistryError("Canonical context exceeds the configured record limit")
|
||||
return records
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LocalContextProvider:
|
||||
repository: CanonicalRepository
|
||||
registry_file: Path
|
||||
|
||||
def __call__(self) -> ValidationContext:
|
||||
path = self.registry_file.expanduser().absolute()
|
||||
try:
|
||||
reject_symlink_components(path)
|
||||
except ValueError as error:
|
||||
raise RegistryError("Local registry path traverses a symbolic link") from error
|
||||
if path.exists() and not path.is_file():
|
||||
raise RegistryError("Local registry file is unavailable")
|
||||
if not path.exists():
|
||||
projects: dict[str, ProjectRecord] = {}
|
||||
policies: dict[str, AutoAcceptPolicy] = {}
|
||||
else:
|
||||
try:
|
||||
registry_bytes = path.read_bytes()
|
||||
except OSError as error:
|
||||
raise RegistryError("Local registry file is unavailable") from error
|
||||
projects = _index(_records(registry_bytes, "projects", ProjectRecord), "project_id")
|
||||
policies = _index(_records(registry_bytes, "policies", AutoAcceptPolicy), "policy_id")
|
||||
|
||||
revision = self.repository.head()
|
||||
projects.update(_canonical_projects(self.repository, revision))
|
||||
policies.update(
|
||||
_canonical_records(
|
||||
self.repository,
|
||||
revision,
|
||||
"policies",
|
||||
AutoAcceptPolicy,
|
||||
"policy_id",
|
||||
)
|
||||
)
|
||||
sources = _canonical_records(
|
||||
self.repository, revision, "sources", SourceRevisionRecord, "revision_id"
|
||||
)
|
||||
nodes = _canonical_records(self.repository, revision, "projects", KnowledgeNode, "node_id")
|
||||
extractions: dict[str, ExtractionRecord] = {}
|
||||
record_count = 0
|
||||
try:
|
||||
record_count += len(sources) + len(nodes)
|
||||
for record_path in self.repository.list_files(revision, "extractions"):
|
||||
if not record_path.endswith(".json"):
|
||||
continue
|
||||
record_count += 1
|
||||
extraction_bytes = self.repository.read_file(revision, record_path)
|
||||
if extraction_bytes is None or len(extraction_bytes) > MAX_CONTEXT_RECORD_BYTES:
|
||||
raise RegistryError("Canonical extraction is unavailable or too large")
|
||||
extraction = ExtractionRecord.model_validate_json(extraction_bytes, strict=True)
|
||||
if extraction_digest(extraction) != extraction.extraction_id:
|
||||
raise RegistryError("Canonical extraction identity is invalid")
|
||||
if extraction.extraction_id in extractions:
|
||||
raise RegistryError("Canonical repository contains duplicate extraction IDs")
|
||||
extractions[extraction.extraction_id] = extraction
|
||||
except (OSError, ValueError, ValidationError) as error:
|
||||
raise RegistryError("Canonical context failed validation") from error
|
||||
if record_count > MAX_CONTEXT_RECORDS:
|
||||
raise RegistryError("Canonical context exceeds the configured record limit")
|
||||
return ValidationContext(
|
||||
projects=projects,
|
||||
policies=policies,
|
||||
sources=sources,
|
||||
extractions=extractions,
|
||||
existing_nodes=nodes,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LocalProjectRegistry:
|
||||
"""Read legacy control entries and canonical project lifecycle records."""
|
||||
|
||||
registry_file: Path
|
||||
repository: CanonicalRepository | None = None
|
||||
index_directory: Path | None = None
|
||||
authority_lock_path: Path | None = None
|
||||
|
||||
def _legacy_projects(self) -> tuple[ProjectRecord, ...]:
|
||||
path = self.registry_file.expanduser().absolute()
|
||||
try:
|
||||
reject_symlink_components(path)
|
||||
except ValueError as error:
|
||||
raise RegistryError("Local registry path traverses a symbolic link") from error
|
||||
if not path.exists():
|
||||
return ()
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RegistryError("Local registry file is unavailable")
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as error:
|
||||
raise RegistryError("Local registry file is unavailable") from error
|
||||
return _records(raw, "projects", ProjectRecord)
|
||||
|
||||
def _projects(self) -> tuple[ProjectRecord, ...]:
|
||||
projects = _index(self._legacy_projects(), "project_id")
|
||||
if self.repository is not None:
|
||||
projects.update(_canonical_projects(self.repository, self.repository.head()))
|
||||
return tuple(projects.values())
|
||||
|
||||
def _policies(self) -> tuple[AutoAcceptPolicy, ...]:
|
||||
path = self.registry_file.expanduser().absolute()
|
||||
if not path.exists():
|
||||
policies: dict[str, AutoAcceptPolicy] = {}
|
||||
elif path.is_symlink() or not path.is_file():
|
||||
raise RegistryError("Local registry file is unavailable")
|
||||
else:
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as error:
|
||||
raise RegistryError("Local registry file is unavailable") from error
|
||||
policies = _index(_records(raw, "policies", AutoAcceptPolicy), "policy_id")
|
||||
if self.repository is not None:
|
||||
policies.update(
|
||||
_canonical_records(
|
||||
self.repository,
|
||||
self.repository.head(),
|
||||
"policies",
|
||||
AutoAcceptPolicy,
|
||||
"policy_id",
|
||||
)
|
||||
)
|
||||
return tuple(policies.values())
|
||||
|
||||
def __call__(self) -> frozenset[str]:
|
||||
return frozenset(_index(self._projects(), "project_id"))
|
||||
|
||||
def records(self) -> tuple[ProjectRecord, ...]:
|
||||
records = tuple(sorted(self._projects(), key=lambda project: project.project_id))
|
||||
response_bytes = sum(
|
||||
len(
|
||||
json.dumps(
|
||||
project.model_dump(mode="json"),
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
)
|
||||
for project in records
|
||||
)
|
||||
if response_bytes > MAX_PROJECT_REGISTRY_RESPONSE_BYTES:
|
||||
raise RegistryError("Project registry listing exceeds the supported size")
|
||||
return records
|
||||
|
||||
def create(self, project: ProjectRecord, policy: AutoAcceptPolicy) -> ProjectRecord:
|
||||
"""Atomically add a project and its explicitly supplied restrictive policy."""
|
||||
|
||||
if project.project_id == "shared" or project.status != ProjectStatus.ACTIVE:
|
||||
raise ValueError("New projects must use a non-reserved ID and active status")
|
||||
if not project.owners:
|
||||
raise ValueError("At least one named project owner is required")
|
||||
if (
|
||||
project.autonomy_policy_id != policy.policy_id
|
||||
or policy.project_id != project.project_id
|
||||
):
|
||||
raise ValueError("Project and initial policy identities must match")
|
||||
if not policy.require_supported_assessment:
|
||||
raise ValueError("Project policies must require supported assessments")
|
||||
if (
|
||||
self.repository is None
|
||||
or self.index_directory is None
|
||||
or self.authority_lock_path is None
|
||||
):
|
||||
raise RegistryError("Canonical project publication is not configured")
|
||||
with self._write_lock():
|
||||
project_index = _index(self._projects(), "project_id")
|
||||
policy_index = _index(self._policies(), "policy_id")
|
||||
if project.project_id in project_index:
|
||||
raise RegistryConflict("Project ID is already registered")
|
||||
if policy.policy_id in policy_index:
|
||||
raise RegistryConflict("Policy ID is already registered")
|
||||
available_projects = set(project_index) | {project.project_id, "shared"}
|
||||
if any(item not in available_projects for item in project.default_includes):
|
||||
raise ValueError("Project default includes must reference registered projects")
|
||||
if project.project_id in project.default_includes:
|
||||
raise ValueError("A project cannot include itself by default")
|
||||
try:
|
||||
self.repository.publish_files(
|
||||
expected_head=self.repository.head(),
|
||||
files={
|
||||
_project_record_path(project.project_id): _project_markdown(project),
|
||||
_policy_record_path(policy.policy_id): _yaml_record(policy),
|
||||
},
|
||||
message=f"Register project {project.project_id}",
|
||||
index_directory=self.index_directory,
|
||||
)
|
||||
except CanonicalConflictError as error:
|
||||
raise RegistryConflict(
|
||||
"Canonical project registry changed during creation"
|
||||
) from error
|
||||
return project
|
||||
|
||||
def update_content(
|
||||
self, project_id: str, *, description: str, north_star: str, key_questions: tuple[str, ...]
|
||||
) -> ProjectRecord:
|
||||
"""Update a project's human-authored description and research questions."""
|
||||
if self.repository is None or self.index_directory is None or self.authority_lock_path is None:
|
||||
raise RegistryError("Canonical project publication is not configured")
|
||||
with self._write_lock():
|
||||
project = _index(self._projects(), "project_id").get(project_id)
|
||||
if project is None:
|
||||
raise RegistryNotFound("Project is not registered")
|
||||
updated = project.model_copy(update={
|
||||
"description": description, "north_star": north_star, "key_questions": key_questions,
|
||||
})
|
||||
try:
|
||||
self.repository.publish_files(
|
||||
expected_head=self.repository.head(),
|
||||
files={_project_record_path(project_id): _project_markdown(updated)},
|
||||
message=f"Update project description and questions for {project_id}",
|
||||
index_directory=self.index_directory,
|
||||
)
|
||||
except CanonicalConflictError as error:
|
||||
raise RegistryConflict("Canonical project registry changed during update") from error
|
||||
return updated
|
||||
|
||||
def archive(self, project_id: str) -> ProjectRecord:
|
||||
"""Mark a project archived while preserving its registry and read history."""
|
||||
|
||||
if project_id == "shared":
|
||||
raise ValueError("The shared scope cannot be archived")
|
||||
if (
|
||||
self.repository is None
|
||||
or self.index_directory is None
|
||||
or self.authority_lock_path is None
|
||||
):
|
||||
raise RegistryError("Canonical project publication is not configured")
|
||||
with self._write_lock():
|
||||
project_index = _index(self._projects(), "project_id")
|
||||
project = project_index.get(project_id)
|
||||
if project is None:
|
||||
raise RegistryNotFound("Project is not registered")
|
||||
if project.status == ProjectStatus.ARCHIVED:
|
||||
return project
|
||||
archived = project.model_copy(update={"status": ProjectStatus.ARCHIVED})
|
||||
try:
|
||||
self.repository.publish_files(
|
||||
expected_head=self.repository.head(),
|
||||
files={_project_record_path(project_id): _project_markdown(archived)},
|
||||
message=f"Archive project {project_id}",
|
||||
index_directory=self.index_directory,
|
||||
)
|
||||
except CanonicalConflictError as error:
|
||||
raise RegistryConflict(
|
||||
"Canonical project registry changed during archive"
|
||||
) from error
|
||||
return archived
|
||||
|
||||
def _write_lock(self) -> AbstractContextManager[Any]:
|
||||
lock_path = self.authority_lock_path
|
||||
if lock_path is None:
|
||||
lock_path = self.registry_file.with_suffix(self.registry_file.suffix + ".lock")
|
||||
lock_path = lock_path.expanduser().absolute()
|
||||
reject_symlink_components(lock_path)
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
lock_path.parent.chmod(0o700)
|
||||
return portalocker.Lock(lock_path, mode="a", timeout=0)
|
||||
|
||||
def default_includes(self, project_id: str) -> tuple[str, ...]:
|
||||
projects = _index(self._projects(), "project_id")
|
||||
project = projects.get(project_id)
|
||||
if project is None:
|
||||
raise RegistryError("Project is unavailable for default scope resolution")
|
||||
return tuple(include for include in project.default_includes if include != "shared")
|
||||
|
||||
|
||||
def _project_record_path(project_id: str) -> str:
|
||||
return f"projects/{project_id}/project.md"
|
||||
|
||||
|
||||
def _policy_record_path(policy_id: str) -> str:
|
||||
import hashlib
|
||||
|
||||
digest = hashlib.sha256(policy_id.encode("utf-8")).hexdigest()
|
||||
return f"policies/{digest}.yaml"
|
||||
|
||||
|
||||
def _yaml_record(record: BaseModel) -> bytes:
|
||||
return yaml.safe_dump(
|
||||
record.model_dump(mode="json"),
|
||||
allow_unicode=True,
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _project_markdown(project: ProjectRecord) -> bytes:
|
||||
return b"---\n" + _yaml_record(project) + b"---\n"
|
||||
|
||||
|
||||
def _canonical_projects(repository: CanonicalRepository, revision: str) -> dict[str, ProjectRecord]:
|
||||
projects: dict[str, ProjectRecord] = {}
|
||||
total_bytes = 0
|
||||
for record_path in repository.list_files(revision, "projects"):
|
||||
if not record_path.endswith("/project.md"):
|
||||
continue
|
||||
raw = repository.read_file(revision, record_path)
|
||||
if raw is None or len(raw) > MAX_CONTEXT_RECORD_BYTES:
|
||||
raise RegistryError("Canonical project record is unavailable or too large")
|
||||
total_bytes += len(raw)
|
||||
if total_bytes > MAX_PROJECT_REGISTRY_RESPONSE_BYTES:
|
||||
raise RegistryError("Canonical project registry exceeds the supported size")
|
||||
if not raw.startswith(b"---\n") or b"\n---\n" not in raw[4:]:
|
||||
raise RegistryError("Canonical project frontmatter is invalid")
|
||||
frontmatter = raw[4:].split(b"\n---\n", maxsplit=1)[0]
|
||||
project = safe_yaml_record(frontmatter, ProjectRecord)
|
||||
if record_path != _project_record_path(project.project_id):
|
||||
raise RegistryError("Canonical project path does not match its stable ID")
|
||||
if project.project_id in projects:
|
||||
raise RegistryError("Canonical repository contains duplicate project IDs")
|
||||
projects[project.project_id] = project
|
||||
if len(projects) > MAX_CONTEXT_RECORDS:
|
||||
raise RegistryError("Canonical project registry exceeds the supported record count")
|
||||
return projects
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Private durable run checkpoints scoped to an authenticated agent identity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import threading
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import portalocker
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
|
||||
from gewu.application.acceptance import ActorContext
|
||||
from gewu.domain.contracts import (
|
||||
RunCheckpointRequest,
|
||||
RunCreateRequest,
|
||||
RunRecord,
|
||||
RunState,
|
||||
Sha256,
|
||||
)
|
||||
from gewu.domain.validation import canonical_json
|
||||
|
||||
|
||||
class RunError(RuntimeError):
|
||||
"""Safe run API failure with a stable transport code."""
|
||||
|
||||
def __init__(self, code: str, message: str, status_code: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class _StoredRun(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
||||
|
||||
owner_id: str
|
||||
create_digest: Sha256
|
||||
record: RunRecord
|
||||
|
||||
|
||||
def _digest(value: object) -> str:
|
||||
return hashlib.sha256(canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
class LocalRunService:
|
||||
"""Atomic checkpoint store with stable owner/run IDs and revision CAS updates."""
|
||||
|
||||
MAX_RECORD_BYTES = 1_000_000
|
||||
|
||||
def __init__(self, *, workflow_root: Path, actor_is_current: Callable[[ActorContext], bool]):
|
||||
self.root = workflow_root.expanduser().absolute() / "runs"
|
||||
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
if self.root.is_symlink() or not self.root.is_dir():
|
||||
raise RuntimeError("GEWU run directory must be a real directory")
|
||||
self.root.chmod(stat.S_IRWXU)
|
||||
self.lock_path = self.root.parent / "locks" / "canonical-writer.lock"
|
||||
self.lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
if self.lock_path.parent.is_symlink() or not self.lock_path.parent.is_dir():
|
||||
raise RuntimeError("GEWU run lock directory must be a real directory")
|
||||
self.lock_path.parent.chmod(stat.S_IRWXU)
|
||||
self.actor_is_current = actor_is_current
|
||||
self._thread_lock = threading.RLock()
|
||||
|
||||
def create(self, request: RunCreateRequest, *, actor: ActorContext) -> RunRecord:
|
||||
if not self.actor_is_current(actor):
|
||||
raise RunError("UNAUTHORIZED", "Actor credential is no longer active", 401)
|
||||
if request.scope.all_projects:
|
||||
raise RunError("SCOPE_REQUIRED", "Runs require an explicit project scope", 422)
|
||||
create_digest = _digest(request.model_dump(mode="json"))
|
||||
path = self._record_path(actor.principal_id, request.run_id)
|
||||
initial = RunRecord(
|
||||
run_id=request.run_id,
|
||||
revision=0,
|
||||
state=RunState.ACTIVE,
|
||||
objective=request.objective,
|
||||
scope=request.scope,
|
||||
lens=request.lens,
|
||||
pack_id=request.pack_id,
|
||||
pack_version=request.pack_version,
|
||||
pack_snapshot_digest=request.pack_snapshot_digest,
|
||||
module_pins=request.module_pins,
|
||||
stage="context",
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
with self._writer_lock():
|
||||
if not self.actor_is_current(actor):
|
||||
raise RunError("UNAUTHORIZED", "Actor credential is no longer active", 401)
|
||||
if path.is_symlink():
|
||||
raise RunError("RUN_RECOVERY_REQUIRED", "Run path is unavailable", 503)
|
||||
if path.exists():
|
||||
current = self._read(path, actor)
|
||||
stored = self._read_stored(path)
|
||||
if stored.create_digest == create_digest:
|
||||
return current
|
||||
raise RunError("IDEMPOTENCY_CONFLICT", "Run ID already binds different input", 409)
|
||||
self._write(
|
||||
path,
|
||||
_StoredRun(
|
||||
owner_id=actor.principal_id,
|
||||
create_digest=create_digest,
|
||||
record=initial,
|
||||
),
|
||||
)
|
||||
return initial
|
||||
|
||||
def status(self, run_id: str, *, actor: ActorContext) -> RunRecord:
|
||||
if not self.actor_is_current(actor):
|
||||
raise RunError("UNAUTHORIZED", "Actor credential is no longer active", 401)
|
||||
return self._read(self._record_path(actor.principal_id, run_id), actor)
|
||||
|
||||
def checkpoint(self, request: RunCheckpointRequest, *, actor: ActorContext) -> RunRecord:
|
||||
if not self.actor_is_current(actor):
|
||||
raise RunError("UNAUTHORIZED", "Actor credential is no longer active", 401)
|
||||
checkpoint_digest = _digest(request.model_dump(mode="json"))
|
||||
path = self._record_path(actor.principal_id, request.run_id)
|
||||
with self._writer_lock():
|
||||
if not self.actor_is_current(actor):
|
||||
raise RunError("UNAUTHORIZED", "Actor credential is no longer active", 401)
|
||||
current = self._read(path, actor)
|
||||
if current.revision == request.expected_revision + 1:
|
||||
if current.checkpoint_digest == checkpoint_digest:
|
||||
return current
|
||||
raise RunError("RUN_REVISION_CONFLICT", "Run checkpoint retry changed content", 409)
|
||||
if current.revision != request.expected_revision:
|
||||
raise RunError("RUN_REVISION_CONFLICT", "Run checkpoint revision is stale", 409)
|
||||
if current.state is not RunState.ACTIVE:
|
||||
raise RunError("RUN_CLOSED", "Completed or blocked runs cannot be updated", 409)
|
||||
updated = current.model_copy(
|
||||
update={
|
||||
"revision": current.revision + 1,
|
||||
"state": request.state,
|
||||
"stage": request.stage,
|
||||
"sources": request.sources,
|
||||
"coverage": request.coverage,
|
||||
"budgets": request.budgets,
|
||||
"pending_operations": request.pending_operations,
|
||||
"proposals": request.proposals,
|
||||
"output_hashes": request.output_hashes,
|
||||
"updated_at": datetime.now(UTC),
|
||||
"checkpoint_digest": checkpoint_digest,
|
||||
}
|
||||
)
|
||||
self._write(
|
||||
path,
|
||||
_StoredRun(
|
||||
owner_id=actor.principal_id,
|
||||
create_digest=self._read_stored(path).create_digest,
|
||||
record=updated,
|
||||
),
|
||||
)
|
||||
return updated
|
||||
|
||||
def _read(self, path: Path, actor: ActorContext) -> RunRecord:
|
||||
stored = self._read_stored(path)
|
||||
if stored.owner_id != actor.principal_id:
|
||||
raise RunError("RUN_NOT_FOUND", "Run is unavailable", 404)
|
||||
return stored.record
|
||||
|
||||
@staticmethod
|
||||
def _read_stored(path: Path) -> _StoredRun:
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size > 1_000_000:
|
||||
raise RunError("RUN_NOT_FOUND", "Run is unavailable", 404)
|
||||
try:
|
||||
return _StoredRun.model_validate_json(path.read_bytes(), strict=True)
|
||||
except (OSError, ValueError, ValidationError) as error:
|
||||
raise RunError("RUN_RECOVERY_REQUIRED", "Run checkpoint is unavailable", 503) from error
|
||||
|
||||
def _record_path(self, principal_id: str, run_id: str) -> Path:
|
||||
if not run_id or len(run_id) > 128:
|
||||
raise RunError("RUN_NOT_FOUND", "Run is unavailable", 404)
|
||||
owner_key = hashlib.sha256(principal_id.encode("utf-8")).hexdigest()
|
||||
run_key = hashlib.sha256(run_id.encode("utf-8")).hexdigest()
|
||||
directory = self.root / owner_key
|
||||
if directory.is_symlink():
|
||||
raise RunError("RUN_RECOVERY_REQUIRED", "Run directory is unavailable", 503)
|
||||
if directory.exists() and not directory.is_dir():
|
||||
raise RunError("RUN_RECOVERY_REQUIRED", "Run directory is unavailable", 503)
|
||||
return directory / f"{run_key}.json"
|
||||
|
||||
def _write(self, path: Path, stored: _StoredRun) -> None:
|
||||
content = stored.model_dump_json().encode("utf-8")
|
||||
if len(content) > self.MAX_RECORD_BYTES:
|
||||
raise RunError("RUN_TOO_LARGE", "Run checkpoint exceeds the one-megabyte limit", 413)
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
if path.parent.is_symlink() or not path.parent.is_dir():
|
||||
raise RunError("RUN_STORE_UNAVAILABLE", "Run directory is unavailable", 503)
|
||||
path.parent.chmod(stat.S_IRWXU)
|
||||
temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
||||
try:
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
if os.name != "nt":
|
||||
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
||||
directory_fd = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
except OSError as error:
|
||||
raise RunError(
|
||||
"RUN_STORE_UNAVAILABLE", "Run checkpoint could not be saved", 503
|
||||
) from error
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
@contextmanager
|
||||
def _writer_lock(self) -> Iterator[None]:
|
||||
with self._thread_lock:
|
||||
try:
|
||||
with portalocker.Lock(self.lock_path, mode="a", timeout=0):
|
||||
yield
|
||||
except portalocker.exceptions.LockException as error:
|
||||
raise RunError("BUSY", "Run store is handling another operation", 409) from error
|
||||
@@ -0,0 +1,998 @@
|
||||
"""Scope and freshness policy for derived canonical search."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import sqlite3
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Collection, Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from gewu.application.identity import Principal
|
||||
from gewu.application.ports import EmbeddingProvider, VectorGeneration, VectorIndex
|
||||
from gewu.domain.contracts import (
|
||||
BudgetProfile,
|
||||
Confidence,
|
||||
EdgeEndpoint,
|
||||
EvidenceReference,
|
||||
Finding,
|
||||
KnowledgeNode,
|
||||
NeighborRequest,
|
||||
ReadEnvelope,
|
||||
Scope,
|
||||
SearchRequest,
|
||||
SourceListRequest,
|
||||
SourceRevisionRecord,
|
||||
TypedEdge,
|
||||
)
|
||||
from gewu.domain.validation import canonical_json, evidence_digest
|
||||
|
||||
|
||||
class SearchError(RuntimeError):
|
||||
"""Safe search failure with an HTTP-compatible status."""
|
||||
|
||||
def __init__(self, status_code: int, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IndexedNode:
|
||||
node: KnowledgeNode
|
||||
score: float
|
||||
expanded_from: str | None = None
|
||||
expansion_edge: TypedEdge | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SearchIndexResult:
|
||||
index_revision: str
|
||||
nodes: tuple[IndexedNode, ...]
|
||||
has_more: bool = False
|
||||
retrieval_mode: Literal["keyword", "hybrid"] = "keyword"
|
||||
warnings: tuple[Finding, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SourceIndexResult:
|
||||
index_revision: str
|
||||
sources: tuple[SourceRevisionRecord, ...]
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SearchEdgeResult:
|
||||
index_revision: str
|
||||
origin: KnowledgeNode | None
|
||||
edges: tuple[SearchEdgeHit, ...]
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SearchEdgeHit:
|
||||
edge: TypedEdge
|
||||
neighbor_endpoint: EdgeEndpoint
|
||||
neighbor_node: KnowledgeNode | None
|
||||
neighbor_source: SourceRevisionRecord | None
|
||||
|
||||
|
||||
class SearchIndex(Protocol):
|
||||
def diagnostic_status(self) -> tuple[str | None, int, bool]: ...
|
||||
|
||||
def search(
|
||||
self,
|
||||
*,
|
||||
query: str,
|
||||
project_ids: tuple[str, ...],
|
||||
include_shared: bool,
|
||||
limit: int,
|
||||
node_types: tuple[str, ...] = (),
|
||||
tags: tuple[str, ...] = (),
|
||||
audience: tuple[str, ...] = (),
|
||||
use_for: tuple[str, ...] = (),
|
||||
domain: tuple[str, ...] = (),
|
||||
confidences: tuple[Confidence, ...] = (),
|
||||
as_of_from: date | None = None,
|
||||
as_of_to: date | None = None,
|
||||
stale_only: bool = False,
|
||||
candidate_node_ids: tuple[str, ...] | None = None,
|
||||
offset: int = 0,
|
||||
) -> SearchIndexResult: ...
|
||||
|
||||
def neighbors(
|
||||
self,
|
||||
*,
|
||||
node_id: str,
|
||||
edge_types: tuple[str, ...],
|
||||
project_ids: tuple[str, ...],
|
||||
include_shared: bool,
|
||||
limit: int,
|
||||
offset: int = 0,
|
||||
) -> SearchEdgeResult: ...
|
||||
|
||||
def list_sources(
|
||||
self,
|
||||
*,
|
||||
project_ids: tuple[str, ...],
|
||||
include_shared: bool,
|
||||
limit: int,
|
||||
offset: int = 0,
|
||||
) -> SourceIndexResult: ...
|
||||
|
||||
|
||||
_VECTOR_CANDIDATE_LIMIT = 100
|
||||
_VECTOR_BUILD_BATCH_SIZE = 32
|
||||
_GRAPH_SEED_LIMIT = 10
|
||||
_GRAPH_EDGE_LIMIT = 50
|
||||
_GRAPH_EXPANSION_SCORE = 1 / 160.5
|
||||
_NODE_CONTENT_PROFILE = "knowledge-node-title-summary-claims-tags-v1"
|
||||
|
||||
|
||||
class HybridSearchIndex:
|
||||
"""Fuse scoped lexical and vector candidates over a pinned vector generation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
lexical: SearchIndex,
|
||||
vectors: VectorIndex,
|
||||
embeddings: EmbeddingProvider,
|
||||
) -> None:
|
||||
self.lexical = lexical
|
||||
self.vectors = vectors
|
||||
self.embeddings = embeddings
|
||||
self._failed_profile: tuple[str, str] | None = None
|
||||
|
||||
def diagnostic_status(self) -> tuple[str | None, int, bool]:
|
||||
return self.lexical.diagnostic_status()
|
||||
|
||||
def cursor_profile(self) -> str:
|
||||
return hashlib.sha256(
|
||||
"\0".join(
|
||||
(
|
||||
"hybrid-rrf-v1",
|
||||
"typed-graph-one-hop-v1",
|
||||
str(_VECTOR_CANDIDATE_LIMIT),
|
||||
str(_GRAPH_SEED_LIMIT),
|
||||
str(_GRAPH_EDGE_LIMIT),
|
||||
self.embeddings.model_id,
|
||||
self.embeddings.artifact_sha256,
|
||||
str(self.embeddings.dimensions),
|
||||
_NODE_CONTENT_PROFILE,
|
||||
)
|
||||
).encode()
|
||||
).hexdigest()
|
||||
|
||||
def search(
|
||||
self,
|
||||
*,
|
||||
query: str,
|
||||
project_ids: tuple[str, ...],
|
||||
include_shared: bool,
|
||||
limit: int,
|
||||
node_types: tuple[str, ...] = (),
|
||||
tags: tuple[str, ...] = (),
|
||||
audience: tuple[str, ...] = (),
|
||||
use_for: tuple[str, ...] = (),
|
||||
domain: tuple[str, ...] = (),
|
||||
confidences: tuple[Confidence, ...] = (),
|
||||
as_of_from: date | None = None,
|
||||
as_of_to: date | None = None,
|
||||
stale_only: bool = False,
|
||||
candidate_node_ids: tuple[str, ...] | None = None,
|
||||
offset: int = 0,
|
||||
) -> SearchIndexResult:
|
||||
if not query.strip() or candidate_node_ids is not None:
|
||||
return self.lexical.search(
|
||||
query=query,
|
||||
project_ids=project_ids,
|
||||
include_shared=include_shared,
|
||||
limit=limit,
|
||||
node_types=node_types,
|
||||
tags=tags,
|
||||
audience=audience,
|
||||
use_for=use_for,
|
||||
domain=domain,
|
||||
confidences=confidences,
|
||||
as_of_from=as_of_from,
|
||||
as_of_to=as_of_to,
|
||||
stale_only=stale_only,
|
||||
candidate_node_ids=candidate_node_ids,
|
||||
offset=offset,
|
||||
)
|
||||
try:
|
||||
lexical_head = self.lexical.search(
|
||||
query=query,
|
||||
project_ids=project_ids,
|
||||
include_shared=include_shared,
|
||||
limit=_VECTOR_CANDIDATE_LIMIT,
|
||||
node_types=node_types,
|
||||
tags=tags,
|
||||
audience=audience,
|
||||
use_for=use_for,
|
||||
domain=domain,
|
||||
confidences=confidences,
|
||||
as_of_from=as_of_from,
|
||||
as_of_to=as_of_to,
|
||||
stale_only=stale_only,
|
||||
)
|
||||
generation = self.vectors.active_generation()
|
||||
if not self._matches_generation(generation, lexical_head.index_revision):
|
||||
raise RuntimeError("Vector generation is unavailable")
|
||||
assert generation is not None
|
||||
query_vector = self.embeddings.embed_queries((query,))[0]
|
||||
vector_hits = self.vectors.search(
|
||||
query=query_vector,
|
||||
project_ids=project_ids,
|
||||
include_shared=include_shared,
|
||||
limit=_VECTOR_CANDIDATE_LIMIT,
|
||||
expected_generation_id=generation.generation_id,
|
||||
)
|
||||
vector_nodes = self.lexical.search(
|
||||
query="",
|
||||
project_ids=project_ids,
|
||||
include_shared=include_shared,
|
||||
limit=_VECTOR_CANDIDATE_LIMIT,
|
||||
node_types=node_types,
|
||||
tags=tags,
|
||||
audience=audience,
|
||||
use_for=use_for,
|
||||
domain=domain,
|
||||
confidences=confidences,
|
||||
as_of_from=as_of_from,
|
||||
as_of_to=as_of_to,
|
||||
stale_only=stale_only,
|
||||
candidate_node_ids=tuple(hit.node_id for hit in vector_hits),
|
||||
)
|
||||
vector_node_by_id = {hit.node.node_id: hit.node for hit in vector_nodes.nodes}
|
||||
fused_scores: defaultdict[str, float] = defaultdict(float)
|
||||
nodes_by_id: dict[str, KnowledgeNode] = {}
|
||||
for rank, hit in enumerate(lexical_head.nodes, start=1):
|
||||
fused_scores[hit.node.node_id] += 1 / (60 + rank)
|
||||
nodes_by_id[hit.node.node_id] = hit.node
|
||||
for rank, vector_hit in enumerate(vector_hits, start=1):
|
||||
node = vector_node_by_id.get(vector_hit.node_id)
|
||||
if node is not None:
|
||||
fused_scores[vector_hit.node_id] += 1 / (60 + rank)
|
||||
nodes_by_id[vector_hit.node_id] = node
|
||||
ranked = sorted(
|
||||
fused_scores,
|
||||
key=lambda node_id: (-fused_scores[node_id], node_id),
|
||||
)
|
||||
graph_candidates: list[tuple[str, TypedEdge, KnowledgeNode]] = []
|
||||
graph_node_ids: set[str] = set()
|
||||
fused_node_ids = set(ranked)
|
||||
examined_edge_count = 0
|
||||
for seed_id in ranked[:_GRAPH_SEED_LIMIT]:
|
||||
remaining = _GRAPH_EDGE_LIMIT - examined_edge_count
|
||||
if remaining <= 0:
|
||||
break
|
||||
neighbors = self.lexical.neighbors(
|
||||
node_id=seed_id,
|
||||
edge_types=(),
|
||||
project_ids=project_ids,
|
||||
include_shared=include_shared,
|
||||
limit=remaining,
|
||||
)
|
||||
examined_edge_count += len(neighbors.edges)
|
||||
for neighbor in neighbors.edges:
|
||||
node = neighbor.neighbor_node
|
||||
if (
|
||||
node is None
|
||||
or node.node_id in fused_node_ids
|
||||
or node.node_id in graph_node_ids
|
||||
):
|
||||
continue
|
||||
graph_node_ids.add(node.node_id)
|
||||
graph_candidates.append((seed_id, neighbor.edge, node))
|
||||
if graph_candidates:
|
||||
eligible_graph_nodes = self.lexical.search(
|
||||
query="",
|
||||
project_ids=project_ids,
|
||||
include_shared=include_shared,
|
||||
limit=len(graph_candidates),
|
||||
node_types=node_types,
|
||||
tags=tags,
|
||||
audience=audience,
|
||||
use_for=use_for,
|
||||
domain=domain,
|
||||
confidences=confidences,
|
||||
as_of_from=as_of_from,
|
||||
as_of_to=as_of_to,
|
||||
stale_only=stale_only,
|
||||
candidate_node_ids=tuple(node.node_id for _, _, node in graph_candidates),
|
||||
)
|
||||
eligible_ids = {hit.node.node_id for hit in eligible_graph_nodes.nodes}
|
||||
expanded_nodes = tuple(
|
||||
IndexedNode(
|
||||
node=node,
|
||||
score=_GRAPH_EXPANSION_SCORE,
|
||||
expanded_from=seed_id,
|
||||
expansion_edge=edge,
|
||||
)
|
||||
for seed_id, edge, node in graph_candidates
|
||||
if node.node_id in eligible_ids
|
||||
)
|
||||
else:
|
||||
expanded_nodes = ()
|
||||
fixed_head = tuple(
|
||||
[IndexedNode(nodes_by_id[node_id], fused_scores[node_id]) for node_id in ranked]
|
||||
+ list(expanded_nodes)
|
||||
)
|
||||
head_start = min(offset, len(fixed_head))
|
||||
head_page = [*fixed_head[head_start : offset + limit]]
|
||||
tail_offset = max(0, offset - len(fixed_head))
|
||||
tail_limit = limit - len(head_page)
|
||||
lexical_tail = SearchIndexResult(lexical_head.index_revision, ())
|
||||
if lexical_head.has_more and tail_limit > 0:
|
||||
lexical_tail = self.lexical.search(
|
||||
query=query,
|
||||
project_ids=project_ids,
|
||||
include_shared=include_shared,
|
||||
limit=tail_limit,
|
||||
node_types=node_types,
|
||||
tags=tags,
|
||||
audience=audience,
|
||||
use_for=use_for,
|
||||
domain=domain,
|
||||
confidences=confidences,
|
||||
as_of_from=as_of_from,
|
||||
as_of_to=as_of_to,
|
||||
stale_only=stale_only,
|
||||
offset=_VECTOR_CANDIDATE_LIMIT + tail_offset,
|
||||
)
|
||||
lexical_tail = SearchIndexResult(
|
||||
index_revision=lexical_tail.index_revision,
|
||||
nodes=tuple(
|
||||
IndexedNode(
|
||||
hit.node,
|
||||
1 / (60 + _VECTOR_CANDIDATE_LIMIT + tail_offset + rank),
|
||||
)
|
||||
for rank, hit in enumerate(lexical_tail.nodes, start=1)
|
||||
),
|
||||
has_more=lexical_tail.has_more,
|
||||
)
|
||||
page = (*head_page, *lexical_tail.nodes)
|
||||
has_more = (
|
||||
offset + len(head_page) < len(fixed_head)
|
||||
or (tail_limit == 0 and lexical_head.has_more)
|
||||
or (tail_limit > 0 and lexical_tail.has_more)
|
||||
)
|
||||
return SearchIndexResult(
|
||||
index_revision=lexical_head.index_revision,
|
||||
nodes=page,
|
||||
has_more=has_more,
|
||||
retrieval_mode="hybrid",
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError, IndexError):
|
||||
warning = Finding(
|
||||
code="HYBRID_RETRIEVAL_DEGRADED",
|
||||
message=(
|
||||
"Vector retrieval is unavailable for this index revision; "
|
||||
"keyword results are shown."
|
||||
),
|
||||
)
|
||||
fallback = self.lexical.search(
|
||||
query=query,
|
||||
project_ids=project_ids,
|
||||
include_shared=include_shared,
|
||||
limit=limit,
|
||||
node_types=node_types,
|
||||
tags=tags,
|
||||
audience=audience,
|
||||
use_for=use_for,
|
||||
domain=domain,
|
||||
confidences=confidences,
|
||||
as_of_from=as_of_from,
|
||||
as_of_to=as_of_to,
|
||||
stale_only=stale_only,
|
||||
offset=offset,
|
||||
)
|
||||
return SearchIndexResult(
|
||||
index_revision=fallback.index_revision,
|
||||
nodes=fallback.nodes,
|
||||
has_more=fallback.has_more,
|
||||
warnings=(warning,),
|
||||
)
|
||||
|
||||
def vector_generation_is_current(self, revision: str) -> bool:
|
||||
active = self.vectors.active_generation()
|
||||
return self._matches_generation(active, revision) or self._failed_profile == (
|
||||
revision,
|
||||
self.cursor_profile(),
|
||||
)
|
||||
|
||||
def build_vector_generation(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
revision: str,
|
||||
nodes: tuple[KnowledgeNode, ...],
|
||||
) -> None:
|
||||
"""Stage vectors and their active pointer inside the canonical index transaction."""
|
||||
|
||||
ordered_nodes = tuple(sorted(nodes, key=lambda node: (node.project_id, node.node_id)))
|
||||
generation_id = self._generation_id(revision)
|
||||
active = self.vectors.active_generation_in_transaction(connection)
|
||||
generation = self.vectors.generation_in_transaction(connection, generation_id)
|
||||
if not self._matches_generation(generation, revision):
|
||||
connection.execute("SAVEPOINT vector_generation_stage")
|
||||
try:
|
||||
if generation is None:
|
||||
self.vectors.build_generation_in_transaction(
|
||||
connection,
|
||||
generation_id=generation_id,
|
||||
revision=revision,
|
||||
model_id=self.embeddings.model_id,
|
||||
artifact_sha256=self.embeddings.artifact_sha256,
|
||||
content_profile=_NODE_CONTENT_PROFILE,
|
||||
dimensions=self.embeddings.dimensions,
|
||||
vectors=self._vector_records(ordered_nodes),
|
||||
)
|
||||
generation = self.vectors.generation_in_transaction(connection, generation_id)
|
||||
if not self._matches_generation(generation, revision):
|
||||
raise RuntimeError("Vector generation metadata does not match the model")
|
||||
self.vectors.activate_in_transaction(
|
||||
connection,
|
||||
generation_id,
|
||||
expected_active=active.generation_id if active is not None else None,
|
||||
)
|
||||
connection.execute("RELEASE SAVEPOINT vector_generation_stage")
|
||||
self._failed_profile = None
|
||||
except (RuntimeError, ValueError, sqlite3.Error):
|
||||
connection.execute("ROLLBACK TO SAVEPOINT vector_generation_stage")
|
||||
connection.execute("RELEASE SAVEPOINT vector_generation_stage")
|
||||
self.vectors.deactivate_in_transaction(connection)
|
||||
self._failed_profile = (revision, self.cursor_profile())
|
||||
return
|
||||
elif active is None or active.generation_id != generation_id:
|
||||
self.vectors.activate_in_transaction(
|
||||
connection,
|
||||
generation_id,
|
||||
expected_active=active.generation_id if active is not None else None,
|
||||
)
|
||||
self._failed_profile = None
|
||||
|
||||
def _generation_id(self, revision: str) -> str:
|
||||
return hashlib.sha256(
|
||||
"\0".join(
|
||||
(
|
||||
revision,
|
||||
self.embeddings.model_id,
|
||||
self.embeddings.artifact_sha256,
|
||||
str(self.embeddings.dimensions),
|
||||
_NODE_CONTENT_PROFILE,
|
||||
)
|
||||
).encode()
|
||||
).hexdigest()
|
||||
|
||||
def _vector_records(
|
||||
self, nodes: tuple[KnowledgeNode, ...]
|
||||
) -> Iterator[tuple[str, str, tuple[float, ...]]]:
|
||||
for start in range(0, len(nodes), _VECTOR_BUILD_BATCH_SIZE):
|
||||
batch = nodes[start : start + _VECTOR_BUILD_BATCH_SIZE]
|
||||
texts = tuple(self._node_text(node) for node in batch)
|
||||
embeddings = self.embeddings.embed_passages(texts)
|
||||
yield from (
|
||||
(node.project_id, node.node_id, vector)
|
||||
for node, vector in zip(batch, embeddings, strict=True)
|
||||
)
|
||||
|
||||
def _matches_generation(self, generation: VectorGeneration | None, revision: str) -> bool:
|
||||
return bool(
|
||||
generation is not None
|
||||
and generation.revision == revision
|
||||
and generation.model_id == self.embeddings.model_id
|
||||
and generation.artifact_sha256 == self.embeddings.artifact_sha256
|
||||
and generation.content_profile == _NODE_CONTENT_PROFILE
|
||||
and generation.dimensions == self.embeddings.dimensions
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _node_text(node: KnowledgeNode) -> str:
|
||||
return "\n".join(
|
||||
(
|
||||
node.title,
|
||||
node.summary,
|
||||
*(claim.text for claim in node.evidence_claims),
|
||||
*node.tags,
|
||||
*node.aliases,
|
||||
)
|
||||
)
|
||||
|
||||
def neighbors(
|
||||
self,
|
||||
*,
|
||||
node_id: str,
|
||||
edge_types: tuple[str, ...],
|
||||
project_ids: tuple[str, ...],
|
||||
include_shared: bool,
|
||||
limit: int,
|
||||
offset: int = 0,
|
||||
) -> SearchEdgeResult:
|
||||
return self.lexical.neighbors(
|
||||
node_id=node_id,
|
||||
edge_types=edge_types,
|
||||
project_ids=project_ids,
|
||||
include_shared=include_shared,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
def list_sources(
|
||||
self,
|
||||
*,
|
||||
project_ids: tuple[str, ...],
|
||||
include_shared: bool,
|
||||
limit: int,
|
||||
offset: int = 0,
|
||||
) -> SourceIndexResult:
|
||||
return self.lexical.list_sources(
|
||||
project_ids=project_ids,
|
||||
include_shared=include_shared,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
def _budgeted_envelope(
|
||||
*,
|
||||
request_id: str,
|
||||
scope: Scope,
|
||||
rows: list[dict[str, object]],
|
||||
citation_groups: list[dict[str, EvidenceReference]],
|
||||
warnings: list[Finding],
|
||||
canonical_revision: str,
|
||||
index_revision: str,
|
||||
budget_tokens: int,
|
||||
retrieval_mode: Literal["keyword", "hybrid", "canonical"],
|
||||
next_cursor_for: Callable[[int], str | None] | None = None,
|
||||
) -> ReadEnvelope:
|
||||
for included_count in range(len(rows), -1, -1):
|
||||
evidence: dict[str, EvidenceReference] = {}
|
||||
for group in citation_groups[:included_count]:
|
||||
evidence.update(group)
|
||||
omitted = len(rows) - included_count
|
||||
omissions = (
|
||||
(
|
||||
Finding(
|
||||
code="BUDGET_TRUNCATED",
|
||||
message=f"Omitted {omitted} lower-ranked result(s) to fit the budget.",
|
||||
),
|
||||
)
|
||||
if omitted
|
||||
else ()
|
||||
)
|
||||
envelope = ReadEnvelope(
|
||||
request_id=request_id,
|
||||
resolved_scope=scope,
|
||||
data=tuple(rows[:included_count]),
|
||||
citations=tuple(evidence[key] for key in sorted(evidence)),
|
||||
warnings=tuple(warnings),
|
||||
omissions=omissions,
|
||||
next_cursor=next_cursor_for(included_count) if next_cursor_for is not None else None,
|
||||
canonical_revision=canonical_revision,
|
||||
index_revision=index_revision,
|
||||
retrieval_mode=retrieval_mode,
|
||||
budget_profile=BudgetProfile(
|
||||
tokenizer="unicode-codepoints-v1", encoding="canonical-json-v1"
|
||||
),
|
||||
tokens_used=0,
|
||||
)
|
||||
envelope = CanonicalSearchService._count_complete_payload(envelope)
|
||||
if envelope.tokens_used <= budget_tokens:
|
||||
if len(canonical_json(envelope.model_dump(mode="json"))) > 1_000_000:
|
||||
raise SearchError(413, "Search response exceeds the one-megabyte response budget")
|
||||
return envelope
|
||||
raise SearchError(413, "Search budget is too small for the required response envelope")
|
||||
|
||||
|
||||
class CanonicalSearchService:
|
||||
"""Authorize explicit project scope, then search a rebuildable index."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
index: SearchIndex,
|
||||
project_ids_provider: Callable[[], Collection[str]],
|
||||
canonical_head: Callable[[], str],
|
||||
default_includes_provider: Callable[[str], tuple[str, ...]] | None = None,
|
||||
) -> None:
|
||||
self.index = index
|
||||
self.project_ids_provider = project_ids_provider
|
||||
self.canonical_head = canonical_head
|
||||
self.default_includes_provider = default_includes_provider
|
||||
|
||||
def diagnostic_index_status(self) -> tuple[str, str | None, str, int | None]:
|
||||
canonical_revision = self.canonical_head()
|
||||
try:
|
||||
index_revision, schema_version, available = self.index.diagnostic_status()
|
||||
except (OSError, RuntimeError):
|
||||
return canonical_revision, None, "unavailable", None
|
||||
if not available:
|
||||
return canonical_revision, None, "unavailable", schema_version
|
||||
if index_revision is None:
|
||||
return canonical_revision, None, "not_built", schema_version
|
||||
state = "current" if index_revision == canonical_revision else "lagging"
|
||||
return canonical_revision, index_revision, state, schema_version
|
||||
|
||||
def search(self, request: SearchRequest, *, principal: Principal) -> ReadEnvelope:
|
||||
project_ids = self._resolve_scope(request.scope, principal)
|
||||
fingerprint = hashlib.sha256(
|
||||
canonical_json(
|
||||
{
|
||||
"query": request.query,
|
||||
"projects": project_ids,
|
||||
"include_shared": request.scope.include_shared,
|
||||
"node_types": tuple(sorted(item.value for item in request.node_types)),
|
||||
"tags": tuple(sorted(request.tags)),
|
||||
"audience": tuple(sorted(request.audience)),
|
||||
"use_for": tuple(sorted(request.use_for)),
|
||||
"domain": tuple(sorted(request.domain)),
|
||||
"confidences": tuple(sorted(item.value for item in request.confidences)),
|
||||
"as_of_from": request.as_of_from.isoformat() if request.as_of_from else None,
|
||||
"as_of_to": request.as_of_to.isoformat() if request.as_of_to else None,
|
||||
"stale_only": request.stale_only,
|
||||
"limit": request.limit,
|
||||
"retrieval_profile": (
|
||||
self.index.cursor_profile()
|
||||
if isinstance(self.index, HybridSearchIndex)
|
||||
else "keyword-v1"
|
||||
),
|
||||
}
|
||||
)
|
||||
).hexdigest()
|
||||
offset, expected_index_revision = self._cursor_offset(request.cursor, fingerprint)
|
||||
|
||||
result = self.index.search(
|
||||
query=request.query,
|
||||
project_ids=project_ids,
|
||||
include_shared=request.scope.include_shared,
|
||||
node_types=tuple(item.value for item in request.node_types),
|
||||
tags=request.tags,
|
||||
audience=request.audience,
|
||||
use_for=request.use_for,
|
||||
domain=request.domain,
|
||||
confidences=request.confidences,
|
||||
as_of_from=request.as_of_from,
|
||||
as_of_to=request.as_of_to,
|
||||
stale_only=request.stale_only,
|
||||
limit=request.limit,
|
||||
offset=offset,
|
||||
)
|
||||
if expected_index_revision is not None and expected_index_revision != result.index_revision:
|
||||
raise SearchError(409, "Search cursor expired because the index revision changed")
|
||||
|
||||
canonical_revision = self.canonical_head()
|
||||
warnings: list[Finding] = list(result.warnings)
|
||||
now = datetime.now(UTC)
|
||||
evidence_by_node: list[dict[str, EvidenceReference]] = []
|
||||
rows: list[dict[str, object]] = []
|
||||
for hit in result.nodes:
|
||||
node = hit.node
|
||||
row: dict[str, object] = {
|
||||
"node": node.model_dump(mode="json"),
|
||||
"retrieval_score": hit.score,
|
||||
}
|
||||
if hit.expanded_from is not None and hit.expansion_edge is not None:
|
||||
row["retrieval_path"] = "graph_expansion"
|
||||
row["expanded_from"] = hit.expanded_from
|
||||
row["expansion_edge"] = hit.expansion_edge.model_dump(mode="json")
|
||||
rows.append(row)
|
||||
if node.stale_after is not None and node.stale_after <= now:
|
||||
warnings.append(
|
||||
Finding(
|
||||
code="STALE_NODE",
|
||||
message=f"Knowledge node {node.node_id} is past its freshness date.",
|
||||
path=node.node_id,
|
||||
)
|
||||
)
|
||||
node_evidence: dict[str, EvidenceReference] = {}
|
||||
for claim in node.evidence_claims:
|
||||
for item in claim.evidence:
|
||||
key = evidence_digest(item)
|
||||
node_evidence[key] = item
|
||||
if hit.expansion_edge is not None:
|
||||
for item in hit.expansion_edge.evidence:
|
||||
key = evidence_digest(item)
|
||||
node_evidence[key] = item
|
||||
evidence_by_node.append(node_evidence)
|
||||
if result.index_revision != canonical_revision:
|
||||
warnings.append(
|
||||
Finding(
|
||||
code="INDEX_LAG",
|
||||
message=(
|
||||
"Search results reflect an older accepted revision; "
|
||||
"canonical reads remain available."
|
||||
),
|
||||
)
|
||||
)
|
||||
return _budgeted_envelope(
|
||||
request_id="search-" + secrets.token_hex(12),
|
||||
scope=Scope(projects=project_ids, include_shared=request.scope.include_shared),
|
||||
rows=rows,
|
||||
citation_groups=evidence_by_node,
|
||||
warnings=warnings,
|
||||
canonical_revision=canonical_revision,
|
||||
index_revision=result.index_revision,
|
||||
budget_tokens=request.budget_tokens,
|
||||
retrieval_mode=result.retrieval_mode,
|
||||
next_cursor_for=(
|
||||
lambda included: (
|
||||
(
|
||||
self._encode_cursor(offset + included, fingerprint, result.index_revision)
|
||||
if result.has_more or included < len(rows)
|
||||
else None
|
||||
)
|
||||
if not result.warnings
|
||||
else None
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _encode_cursor(offset: int, fingerprint: str, index_revision: str) -> str:
|
||||
payload = canonical_json(
|
||||
{"offset": offset, "fingerprint": fingerprint, "index_revision": index_revision}
|
||||
)
|
||||
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
|
||||
|
||||
@staticmethod
|
||||
def _cursor_offset(
|
||||
cursor: str | None, fingerprint: str, *, max_offset: int = 100_000
|
||||
) -> tuple[int, str | None]:
|
||||
if cursor is None:
|
||||
return 0, None
|
||||
try:
|
||||
padded = cursor + "=" * (-len(cursor) % 4)
|
||||
payload = json.loads(base64.b64decode(padded, altchars=b"-_", validate=True))
|
||||
offset = payload["offset"]
|
||||
if (
|
||||
not isinstance(payload, dict)
|
||||
or set(payload) != {"offset", "fingerprint", "index_revision"}
|
||||
or type(offset) is not int
|
||||
or not 0 <= offset <= max_offset
|
||||
or payload["fingerprint"] != fingerprint
|
||||
or not isinstance(payload["index_revision"], str)
|
||||
or len(payload["index_revision"]) not in {40, 64}
|
||||
or any(
|
||||
character not in "0123456789abcdef" for character in payload["index_revision"]
|
||||
)
|
||||
):
|
||||
raise ValueError("cursor does not match this search")
|
||||
return offset, payload["index_revision"]
|
||||
except (ValueError, KeyError, TypeError, json.JSONDecodeError) as error:
|
||||
raise SearchError(422, "Search cursor is invalid for this query and scope") from error
|
||||
|
||||
def list_projects(self, *, principal: Principal) -> tuple[str, ...]:
|
||||
"""List only projects visible to the authenticated principal."""
|
||||
|
||||
return tuple(
|
||||
sorted(
|
||||
project_id
|
||||
for project_id in set(self.project_ids_provider())
|
||||
if principal.can_read_project(project_id)
|
||||
)
|
||||
)
|
||||
|
||||
def _resolve_scope(self, scope: Scope, principal: Principal) -> tuple[str, ...]:
|
||||
registered_projects = set(self.project_ids_provider())
|
||||
if scope.all_projects:
|
||||
if "admin" not in principal.roles:
|
||||
raise SearchError(403, "All-project search requires an administrator")
|
||||
project_ids = tuple(sorted(registered_projects))
|
||||
else:
|
||||
if scope.include_projects is not None:
|
||||
includes = scope.include_projects
|
||||
elif len(scope.projects) == 1 and self.default_includes_provider is not None:
|
||||
includes = self.default_includes_provider(scope.projects[0])
|
||||
else:
|
||||
includes = ()
|
||||
includes = tuple(project_id for project_id in includes if project_id != "shared")
|
||||
project_ids = tuple(dict.fromkeys((*scope.projects, *includes)))
|
||||
unknown = set(project_ids) - registered_projects - {"shared"}
|
||||
if unknown:
|
||||
raise SearchError(404, "Search scope contains an unknown project")
|
||||
if not project_ids:
|
||||
raise SearchError(422, "Search scope resolved to no projects")
|
||||
if any(not principal.can_read_project(project_id) for project_id in project_ids):
|
||||
raise SearchError(403, "Credential cannot read every requested project")
|
||||
if scope.include_shared and not principal.can_read_project("shared"):
|
||||
raise SearchError(403, "Credential cannot read shared knowledge")
|
||||
return project_ids
|
||||
|
||||
def neighbors(self, request: NeighborRequest, *, principal: Principal) -> ReadEnvelope:
|
||||
project_ids = self._resolve_scope(request.scope, principal)
|
||||
fingerprint = hashlib.sha256(
|
||||
canonical_json(
|
||||
{
|
||||
"node_id": request.node_id,
|
||||
"projects": project_ids,
|
||||
"include_shared": request.scope.include_shared,
|
||||
"edge_types": request.edge_types,
|
||||
"limit": request.limit,
|
||||
}
|
||||
)
|
||||
).hexdigest()
|
||||
offset, expected_index_revision = self._cursor_offset(
|
||||
request.cursor, fingerprint, max_offset=500_000
|
||||
)
|
||||
result = self.index.neighbors(
|
||||
node_id=request.node_id,
|
||||
edge_types=tuple(request.edge_types),
|
||||
project_ids=project_ids,
|
||||
include_shared=request.scope.include_shared,
|
||||
limit=request.limit,
|
||||
offset=offset,
|
||||
)
|
||||
if expected_index_revision is not None and expected_index_revision != result.index_revision:
|
||||
raise SearchError(409, "Neighbor cursor expired because the index revision changed")
|
||||
origin = result.origin
|
||||
allowed = set(project_ids)
|
||||
if request.scope.include_shared:
|
||||
allowed.add("shared")
|
||||
if origin is None or origin.project_id not in allowed:
|
||||
raise SearchError(404, "Knowledge node was not found within the requested scope")
|
||||
|
||||
canonical_revision = self.canonical_head()
|
||||
warnings: list[Finding] = []
|
||||
stale_ids: set[str] = set()
|
||||
if origin.stale_after is not None and origin.stale_after <= datetime.now(UTC):
|
||||
stale_ids.add(origin.node_id)
|
||||
rows: list[dict[str, object]] = []
|
||||
citation_groups: list[dict[str, EvidenceReference]] = []
|
||||
for hit in result.edges:
|
||||
endpoint = hit.neighbor_endpoint
|
||||
if endpoint.kind == "node":
|
||||
neighbor = hit.neighbor_node
|
||||
if neighbor is None or neighbor.project_id not in allowed:
|
||||
continue
|
||||
neighbor_value: dict[str, object] = {
|
||||
"kind": "node",
|
||||
"id": endpoint.id,
|
||||
"record": neighbor.model_dump(mode="json"),
|
||||
}
|
||||
if neighbor.stale_after is not None and neighbor.stale_after <= datetime.now(UTC):
|
||||
stale_ids.add(neighbor.node_id)
|
||||
elif endpoint.kind == "project":
|
||||
if endpoint.id not in allowed:
|
||||
continue
|
||||
neighbor_value = {"kind": "project", "id": endpoint.id}
|
||||
neighbor = None
|
||||
else:
|
||||
source = hit.neighbor_source
|
||||
if source is None or not (set(source.ingested_for) & allowed):
|
||||
continue
|
||||
neighbor_value = {
|
||||
"kind": "source",
|
||||
"id": endpoint.id,
|
||||
"record": source.model_dump(mode="json"),
|
||||
}
|
||||
neighbor = None
|
||||
rows.append(
|
||||
{
|
||||
"from_node": {
|
||||
"node_id": origin.node_id,
|
||||
"project_id": origin.project_id,
|
||||
"title": origin.title,
|
||||
},
|
||||
"edge": hit.edge.model_dump(mode="json"),
|
||||
"neighbor": neighbor_value,
|
||||
}
|
||||
)
|
||||
group: dict[str, EvidenceReference] = {}
|
||||
for item in hit.edge.evidence:
|
||||
group[evidence_digest(item)] = item
|
||||
if neighbor is not None:
|
||||
for claim in neighbor.claims:
|
||||
for item in claim.evidence:
|
||||
group[evidence_digest(item)] = item
|
||||
citation_groups.append(group)
|
||||
warnings.extend(
|
||||
Finding(
|
||||
code="STALE_NODE",
|
||||
message=f"Knowledge node {node_id} is past its freshness date.",
|
||||
path=node_id,
|
||||
)
|
||||
for node_id in sorted(stale_ids)
|
||||
)
|
||||
if result.index_revision != canonical_revision:
|
||||
warnings.append(
|
||||
Finding(
|
||||
code="INDEX_LAG",
|
||||
message=(
|
||||
"Neighbor results reflect an older accepted revision; "
|
||||
"canonical reads remain available."
|
||||
),
|
||||
)
|
||||
)
|
||||
return _budgeted_envelope(
|
||||
request_id="neighbors-" + secrets.token_hex(12),
|
||||
scope=Scope(projects=project_ids, include_shared=request.scope.include_shared),
|
||||
rows=rows,
|
||||
citation_groups=citation_groups,
|
||||
warnings=warnings,
|
||||
canonical_revision=canonical_revision,
|
||||
index_revision=result.index_revision,
|
||||
budget_tokens=request.budget_tokens,
|
||||
retrieval_mode="keyword",
|
||||
next_cursor_for=(
|
||||
lambda included: (
|
||||
self._encode_cursor(offset + included, fingerprint, result.index_revision)
|
||||
if result.has_more or included < len(rows)
|
||||
else None
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def list_sources(self, request: SourceListRequest, *, principal: Principal) -> ReadEnvelope:
|
||||
project_ids = self._resolve_scope(request.scope, principal)
|
||||
fingerprint = hashlib.sha256(
|
||||
canonical_json(
|
||||
{
|
||||
"projects": project_ids,
|
||||
"include_shared": request.scope.include_shared,
|
||||
"limit": request.limit,
|
||||
}
|
||||
)
|
||||
).hexdigest()
|
||||
offset, expected_index_revision = self._cursor_offset(
|
||||
request.cursor, fingerprint, max_offset=500_000
|
||||
)
|
||||
result = self.index.list_sources(
|
||||
project_ids=project_ids,
|
||||
include_shared=request.scope.include_shared,
|
||||
limit=request.limit,
|
||||
offset=offset,
|
||||
)
|
||||
if expected_index_revision is not None and expected_index_revision != result.index_revision:
|
||||
raise SearchError(409, "Source cursor expired because the index revision changed")
|
||||
|
||||
canonical_revision = self.canonical_head()
|
||||
rows: list[dict[str, object]] = [
|
||||
{"source": source.model_dump(mode="json")} for source in result.sources
|
||||
]
|
||||
warnings: list[Finding] = []
|
||||
if result.index_revision != canonical_revision:
|
||||
warnings.append(
|
||||
Finding(
|
||||
code="INDEX_LAG",
|
||||
message=(
|
||||
"Source results reflect an older accepted revision; "
|
||||
"canonical source reads remain available."
|
||||
),
|
||||
)
|
||||
)
|
||||
return _budgeted_envelope(
|
||||
request_id="sources-" + secrets.token_hex(12),
|
||||
scope=Scope(projects=project_ids, include_shared=request.scope.include_shared),
|
||||
rows=rows,
|
||||
citation_groups=[{} for _ in rows],
|
||||
warnings=warnings,
|
||||
canonical_revision=canonical_revision,
|
||||
index_revision=result.index_revision,
|
||||
budget_tokens=request.budget_tokens,
|
||||
retrieval_mode="keyword",
|
||||
next_cursor_for=(
|
||||
lambda included: (
|
||||
self._encode_cursor(offset + included, fingerprint, result.index_revision)
|
||||
if result.has_more or included < len(rows)
|
||||
else None
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _count_complete_payload(envelope: ReadEnvelope) -> ReadEnvelope:
|
||||
counted = envelope
|
||||
for _ in range(4):
|
||||
serialized = canonical_json(counted.model_dump(mode="json"))
|
||||
count = len(serialized.decode("utf-8"))
|
||||
if count == counted.tokens_used:
|
||||
return counted
|
||||
counted = counted.model_copy(update={"tokens_used": count})
|
||||
return counted
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Strict serializers for locally persisted typed application records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
def safe_yaml_record[RecordT: BaseModel](raw: bytes, expected_type: type[RecordT]) -> RecordT:
|
||||
"""Parse YAML without custom tags or duplicate keys, then validate strictly."""
|
||||
|
||||
class UniqueKeyLoader(yaml.SafeLoader):
|
||||
pass
|
||||
|
||||
def construct_mapping(
|
||||
loader: yaml.SafeLoader, node: yaml.MappingNode, deep: bool = False
|
||||
) -> dict[object, object]:
|
||||
keys: set[object] = set()
|
||||
mapping: dict[object, object] = {}
|
||||
for key_node, value_node in node.value:
|
||||
key = loader.construct_object(key_node, deep=deep)
|
||||
if key in keys:
|
||||
raise ValueError("duplicate YAML key")
|
||||
keys.add(key)
|
||||
mapping[key] = loader.construct_object(value_node, deep=deep)
|
||||
return mapping
|
||||
|
||||
UniqueKeyLoader.add_constructor(
|
||||
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
|
||||
construct_mapping,
|
||||
)
|
||||
value = yaml.load(raw, Loader=UniqueKeyLoader)
|
||||
return expected_type.model_validate_json(json.dumps(value, ensure_ascii=False), strict=True)
|
||||
@@ -0,0 +1,652 @@
|
||||
"""Bounded, resumable local upload and content-addressed artifact storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO
|
||||
|
||||
import portalocker
|
||||
|
||||
from gewu.application.paths import reject_symlink_components
|
||||
from gewu.domain.contracts import ExtractionRecord, Sha256
|
||||
from gewu.domain.validation import extraction_digest
|
||||
|
||||
MAX_ARTIFACT_BYTES = 50 * 1024 * 1024
|
||||
MAX_CAPTURE_RECEIPTS = 1024
|
||||
CHUNK_BYTES = 512 * 1024
|
||||
IDLE_EXPIRY_SECONDS = 10 * 60
|
||||
ALLOWED_MEDIA_TYPES = {
|
||||
"application/pdf",
|
||||
"application/x-subrip",
|
||||
"text/html",
|
||||
"text/markdown",
|
||||
"text/plain",
|
||||
"text/vtt",
|
||||
}
|
||||
ALLOWED_SUFFIXES = {".htm", ".html", ".txt", ".md", ".markdown", ".pdf", ".srt", ".vtt"}
|
||||
_SAFE_UPLOAD_ID = re.compile(r"^[A-Za-z0-9_-]{24,64}$")
|
||||
|
||||
|
||||
class UploadError(ValueError):
|
||||
"""Safe client-facing upload failure with a stable error code."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UploadSession:
|
||||
upload_id: str
|
||||
verified_offsets: tuple[int, ...]
|
||||
chunk_bytes: int
|
||||
max_artifact_bytes: int
|
||||
expires_at: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ArtifactReceipt:
|
||||
sha256: Sha256
|
||||
byte_length: int
|
||||
media_type: str
|
||||
status: str = "staged"
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while block := stream.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _write_json_atomically(path: Path, payload: dict[str, object]) -> None:
|
||||
temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
||||
data = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
try:
|
||||
with temporary.open("xb") as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class LocalArtifactStore:
|
||||
"""Filesystem adapter. Roots are service-owned and never model-controlled."""
|
||||
|
||||
def __init__(self, data_root: Path) -> None:
|
||||
self.root = data_root.expanduser().absolute()
|
||||
reject_symlink_components(self.root)
|
||||
self.upload_root = self.root / "staging" / "uploads"
|
||||
self.artifact_root = self.root / "artifacts" / "sha256"
|
||||
self.extraction_root = self.root / "extractions" / "sha256"
|
||||
self.capture_receipt_root = self.root / "staging" / "capture-receipts"
|
||||
self._lock = threading.RLock()
|
||||
for directory in (
|
||||
self.upload_root,
|
||||
self.artifact_root,
|
||||
self.extraction_root,
|
||||
self.capture_receipt_root,
|
||||
):
|
||||
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
if directory.is_symlink() or not directory.is_dir():
|
||||
raise RuntimeError("GEWU data directory must be a real directory")
|
||||
directory.chmod(stat.S_IRWXU)
|
||||
|
||||
@contextmanager
|
||||
def capture_receipt_guard(self) -> Iterator[None]:
|
||||
"""Serialize the bounded text-capture idempotency journal across workers."""
|
||||
|
||||
lock_path = self.capture_receipt_root / ".capture.lock"
|
||||
reject_symlink_components(lock_path)
|
||||
with portalocker.Lock(lock_path, mode="a", timeout=30):
|
||||
yield
|
||||
|
||||
def get_capture_receipt(
|
||||
self,
|
||||
capture_id: str,
|
||||
*,
|
||||
owner_id: str,
|
||||
project_id: str,
|
||||
request_sha256: str,
|
||||
) -> dict[str, object] | None:
|
||||
path = self._capture_receipt_path(capture_id, owner_id)
|
||||
if path.is_symlink() or not path.exists():
|
||||
return None
|
||||
try:
|
||||
value = json.loads(path.read_bytes())
|
||||
except (OSError, ValueError) as error:
|
||||
raise UploadError(
|
||||
"CAPTURE_STATE_INVALID", "Pasted-text capture state is invalid"
|
||||
) from error
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("capture_id") != capture_id
|
||||
or value.get("owner_id") != owner_id
|
||||
):
|
||||
raise UploadError("CAPTURE_STATE_INVALID", "Pasted-text capture state is invalid")
|
||||
if value.get("project_id") != project_id or value.get("request_sha256") != request_sha256:
|
||||
raise UploadError("CAPTURE_CONFLICT", "Capture ID was already used for different input")
|
||||
result = value.get("result")
|
||||
if not isinstance(result, dict):
|
||||
raise UploadError("CAPTURE_STATE_INVALID", "Pasted-text capture state is invalid")
|
||||
path.touch(exist_ok=True)
|
||||
os.utime(path, None)
|
||||
return result
|
||||
|
||||
def save_capture_receipt(
|
||||
self,
|
||||
capture_id: str,
|
||||
*,
|
||||
owner_id: str,
|
||||
project_id: str,
|
||||
request_sha256: str,
|
||||
result: dict[str, object],
|
||||
) -> None:
|
||||
path = self._capture_receipt_path(capture_id, owner_id)
|
||||
existing = self.get_capture_receipt(
|
||||
capture_id,
|
||||
owner_id=owner_id,
|
||||
project_id=project_id,
|
||||
request_sha256=request_sha256,
|
||||
)
|
||||
if existing is not None:
|
||||
if existing != result:
|
||||
raise UploadError("CAPTURE_CONFLICT", "Capture ID already has a different result")
|
||||
return
|
||||
_write_json_atomically(
|
||||
path,
|
||||
{
|
||||
"capture_id": capture_id,
|
||||
"owner_id": owner_id,
|
||||
"project_id": project_id,
|
||||
"request_sha256": request_sha256,
|
||||
"result": result,
|
||||
},
|
||||
)
|
||||
receipts = sorted(
|
||||
(item for item in self.capture_receipt_root.glob("*.json") if item != path),
|
||||
key=lambda item: item.stat().st_mtime_ns,
|
||||
)
|
||||
for expired in receipts[: max(0, len(receipts) + 1 - MAX_CAPTURE_RECEIPTS)]:
|
||||
expired.unlink(missing_ok=True)
|
||||
|
||||
def _capture_receipt_path(self, capture_id: str, owner_id: str) -> Path:
|
||||
key = hashlib.sha256(f"{owner_id}\0{capture_id}".encode()).hexdigest()
|
||||
return self.capture_receipt_root / f"{key}.json"
|
||||
|
||||
def begin(
|
||||
self,
|
||||
*,
|
||||
filename: str,
|
||||
media_type: str,
|
||||
byte_length: int,
|
||||
expected_sha256: str,
|
||||
owner_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
now: int | None = None,
|
||||
) -> UploadSession:
|
||||
suffix = Path(filename).suffix.lower()
|
||||
expected_media = {
|
||||
".pdf": "application/pdf",
|
||||
".srt": "application/x-subrip",
|
||||
".vtt": "text/vtt",
|
||||
".html": "text/html",
|
||||
".htm": "text/html",
|
||||
".md": "text/markdown",
|
||||
".markdown": "text/markdown",
|
||||
".txt": "text/plain",
|
||||
}
|
||||
if suffix not in ALLOWED_SUFFIXES or expected_media.get(suffix) != media_type:
|
||||
raise UploadError(
|
||||
"UNSUPPORTED_MEDIA_TYPE", "Only PDF and UTF-8 HTML, text and Markdown are supported"
|
||||
)
|
||||
if byte_length < 1 or byte_length > MAX_ARTIFACT_BYTES:
|
||||
raise UploadError("UPLOAD_TOO_LARGE", "Artifact size is outside the supported limit")
|
||||
if not re.fullmatch(r"[a-f0-9]{64}", expected_sha256):
|
||||
raise UploadError(
|
||||
"INVALID_DIGEST", "Expected SHA-256 must be 64 lowercase hex characters"
|
||||
)
|
||||
created = int(time.time()) if now is None else now
|
||||
upload_id = secrets.token_urlsafe(24)
|
||||
directory = self.upload_root / upload_id
|
||||
with self._lock:
|
||||
directory.mkdir(mode=0o700)
|
||||
metadata: dict[str, object] = {
|
||||
"upload_id": upload_id,
|
||||
"media_type": media_type,
|
||||
"byte_length": byte_length,
|
||||
"expected_sha256": expected_sha256,
|
||||
"owner_id": owner_id,
|
||||
"project_id": project_id,
|
||||
"created_at": created,
|
||||
"updated_at": created,
|
||||
"suffix": suffix,
|
||||
"chunk_sha256": {},
|
||||
}
|
||||
_write_json_atomically(directory / "session.json", metadata)
|
||||
return UploadSession(
|
||||
upload_id, (), CHUNK_BYTES, MAX_ARTIFACT_BYTES, created + IDLE_EXPIRY_SECONDS
|
||||
)
|
||||
|
||||
def _session_directory(self, upload_id: str) -> Path:
|
||||
if not _SAFE_UPLOAD_ID.fullmatch(upload_id):
|
||||
raise UploadError("UPLOAD_NOT_FOUND", "Upload session does not exist")
|
||||
directory = self.upload_root / upload_id
|
||||
if directory.is_symlink() or not directory.is_dir():
|
||||
raise UploadError("UPLOAD_NOT_FOUND", "Upload session does not exist")
|
||||
return directory
|
||||
|
||||
def _load_session(self, directory: Path) -> dict[str, object]:
|
||||
metadata_path = directory / "session.json"
|
||||
if metadata_path.is_symlink() or not metadata_path.is_file():
|
||||
raise UploadError("UPLOAD_NOT_FOUND", "Upload session does not exist")
|
||||
try:
|
||||
data = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
||||
raise UploadError("UPLOAD_CORRUPT", "Upload session metadata is unavailable") from error
|
||||
if not isinstance(data, dict):
|
||||
raise UploadError("UPLOAD_CORRUPT", "Upload session metadata is unavailable")
|
||||
return data
|
||||
|
||||
def _check_active(self, metadata: dict[str, object], now: int) -> None:
|
||||
updated_at = metadata.get("updated_at")
|
||||
if not isinstance(updated_at, int) or now - updated_at > IDLE_EXPIRY_SECONDS:
|
||||
raise UploadError("UPLOAD_EXPIRED", "Upload session expired; start a new upload")
|
||||
|
||||
@staticmethod
|
||||
def _check_owner(metadata: dict[str, object], owner_id: str | None) -> None:
|
||||
if metadata.get("owner_id") != owner_id:
|
||||
raise UploadError("UPLOAD_NOT_FOUND", "Upload session does not exist")
|
||||
|
||||
def _byte_length(self, metadata: dict[str, object]) -> int:
|
||||
value = metadata.get("byte_length")
|
||||
if not isinstance(value, int) or value < 1 or value > MAX_ARTIFACT_BYTES:
|
||||
raise UploadError("UPLOAD_CORRUPT", "Upload session metadata is unavailable")
|
||||
return value
|
||||
|
||||
def verified_offsets(
|
||||
self, upload_id: str, *, owner_id: str | None = None, now: int | None = None
|
||||
) -> tuple[int, ...]:
|
||||
current = int(time.time()) if now is None else now
|
||||
with self._lock:
|
||||
directory = self._session_directory(upload_id)
|
||||
metadata = self._load_session(directory)
|
||||
self._check_owner(metadata, owner_id)
|
||||
self._check_active(metadata, current)
|
||||
byte_length = self._byte_length(metadata)
|
||||
offsets: list[int] = []
|
||||
for offset in range(0, byte_length, CHUNK_BYTES):
|
||||
chunk_path = directory / f"{offset:020d}.chunk"
|
||||
if chunk_path.is_symlink() or not chunk_path.is_file():
|
||||
break
|
||||
expected_length = min(CHUNK_BYTES, byte_length - offset)
|
||||
if chunk_path.stat().st_size != expected_length:
|
||||
break
|
||||
chunk_digests = metadata.get("chunk_sha256")
|
||||
expected_digest = (
|
||||
chunk_digests.get(str(offset)) if isinstance(chunk_digests, dict) else None
|
||||
)
|
||||
if not isinstance(expected_digest, str) or not secrets.compare_digest(
|
||||
_sha256_file(chunk_path), expected_digest
|
||||
):
|
||||
break
|
||||
offsets.append(offset)
|
||||
return tuple(offsets)
|
||||
|
||||
def put_chunk(
|
||||
self,
|
||||
upload_id: str,
|
||||
*,
|
||||
offset: int,
|
||||
content: bytes,
|
||||
chunk_sha256: str,
|
||||
owner_id: str | None = None,
|
||||
now: int | None = None,
|
||||
) -> tuple[int, ...]:
|
||||
current = int(time.time()) if now is None else now
|
||||
if not content or len(content) > CHUNK_BYTES:
|
||||
raise UploadError("CHUNK_SIZE_INVALID", "Chunk must contain 1 to 512 KiB")
|
||||
actual_digest = hashlib.sha256(content).hexdigest()
|
||||
if not secrets.compare_digest(actual_digest, chunk_sha256):
|
||||
raise UploadError("CHUNK_DIGEST_MISMATCH", "Chunk SHA-256 did not match its bytes")
|
||||
with self._lock:
|
||||
directory = self._session_directory(upload_id)
|
||||
metadata = self._load_session(directory)
|
||||
self._check_owner(metadata, owner_id)
|
||||
self._check_active(metadata, current)
|
||||
byte_length = self._byte_length(metadata)
|
||||
if offset < 0 or offset % CHUNK_BYTES or offset >= byte_length:
|
||||
raise UploadError(
|
||||
"CHUNK_OFFSET_INVALID", "Chunk offset is outside the upload sequence"
|
||||
)
|
||||
required_length = min(CHUNK_BYTES, byte_length - offset)
|
||||
if len(content) != required_length:
|
||||
raise UploadError(
|
||||
"CHUNK_SIZE_INVALID", "Chunk length does not match its upload position"
|
||||
)
|
||||
|
||||
offsets = self.verified_offsets(upload_id, owner_id=owner_id, now=current)
|
||||
chunk_path = directory / f"{offset:020d}.chunk"
|
||||
if offset in offsets:
|
||||
if not secrets.compare_digest(_sha256_file(chunk_path), actual_digest):
|
||||
raise UploadError(
|
||||
"IDEMPOTENCY_CONFLICT", "Chunk offset already contains different bytes"
|
||||
)
|
||||
return offsets
|
||||
if offset != len(offsets) * CHUNK_BYTES:
|
||||
raise UploadError("CHUNK_OFFSET_INVALID", "Chunks must be uploaded in order")
|
||||
with chunk_path.open("xb") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
chunk_digests = metadata.get("chunk_sha256")
|
||||
if not isinstance(chunk_digests, dict):
|
||||
raise UploadError("UPLOAD_CORRUPT", "Upload session metadata is unavailable")
|
||||
chunk_digests[str(offset)] = actual_digest
|
||||
metadata["chunk_sha256"] = chunk_digests
|
||||
metadata["updated_at"] = current
|
||||
_write_json_atomically(directory / "session.json", metadata)
|
||||
return offsets + (offset,)
|
||||
|
||||
def finalize(
|
||||
self, upload_id: str, *, owner_id: str | None = None, now: int | None = None
|
||||
) -> ArtifactReceipt:
|
||||
current = int(time.time()) if now is None else now
|
||||
with self._lock:
|
||||
directory = self._session_directory(upload_id)
|
||||
metadata = self._load_session(directory)
|
||||
self._check_owner(metadata, owner_id)
|
||||
self._check_active(metadata, current)
|
||||
byte_length = self._byte_length(metadata)
|
||||
expected_sha256 = metadata.get("expected_sha256")
|
||||
if not isinstance(expected_sha256, str) or not re.fullmatch(
|
||||
r"[a-f0-9]{64}", expected_sha256
|
||||
):
|
||||
raise UploadError("UPLOAD_CORRUPT", "Upload session metadata is unavailable")
|
||||
offsets = self.verified_offsets(upload_id, owner_id=owner_id, now=current)
|
||||
expected_offsets = tuple(range(0, byte_length, CHUNK_BYTES))
|
||||
if offsets != expected_offsets:
|
||||
raise UploadError("UPLOAD_INCOMPLETE", "Upload is missing one or more chunks")
|
||||
|
||||
temporary = directory / f".{secrets.token_hex(12)}.artifact"
|
||||
digest = hashlib.sha256()
|
||||
media_type = metadata.get("media_type")
|
||||
is_pdf = media_type == "application/pdf"
|
||||
decoder = None if is_pdf else codecs.getincrementaldecoder("utf-8")("strict")
|
||||
header = bytearray()
|
||||
try:
|
||||
with temporary.open("xb") as output:
|
||||
for offset in expected_offsets:
|
||||
chunk_path = directory / f"{offset:020d}.chunk"
|
||||
if chunk_path.is_symlink():
|
||||
raise UploadError("UPLOAD_CORRUPT", "Upload chunk is unavailable")
|
||||
with chunk_path.open("rb") as source:
|
||||
while block := source.read(64 * 1024):
|
||||
digest.update(block)
|
||||
if len(header) < 5:
|
||||
header.extend(block[: 5 - len(header)])
|
||||
if decoder is not None:
|
||||
try:
|
||||
decoder.decode(block)
|
||||
except UnicodeDecodeError as error:
|
||||
raise UploadError(
|
||||
"TEXT_ENCODING_INVALID", "Text artifacts must use UTF-8"
|
||||
) from error
|
||||
output.write(block)
|
||||
if decoder is not None:
|
||||
try:
|
||||
decoder.decode(b"", final=True)
|
||||
except UnicodeDecodeError as error:
|
||||
raise UploadError(
|
||||
"TEXT_ENCODING_INVALID", "Text artifacts must use UTF-8"
|
||||
) from error
|
||||
if is_pdf and bytes(header) != b"%PDF-":
|
||||
raise UploadError("PDF_INVALID", "PDF artifact signature is invalid")
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
|
||||
if digest.hexdigest() != expected_sha256:
|
||||
raise UploadError(
|
||||
"ARTIFACT_DIGEST_MISMATCH", "Artifact SHA-256 did not match upload"
|
||||
)
|
||||
artifact_digest = digest.hexdigest()
|
||||
destination_directory = self.artifact_root / artifact_digest[:2]
|
||||
destination_directory.mkdir(exist_ok=True, mode=0o700)
|
||||
if destination_directory.is_symlink():
|
||||
raise UploadError(
|
||||
"ARTIFACT_STORE_UNAVAILABLE", "Artifact storage is unavailable"
|
||||
)
|
||||
destination = destination_directory / artifact_digest
|
||||
if destination.exists():
|
||||
if destination.is_symlink() or destination.stat().st_size != byte_length:
|
||||
raise UploadError(
|
||||
"ARTIFACT_STORE_CONFLICT", "Stored artifact failed integrity checks"
|
||||
)
|
||||
if not secrets.compare_digest(_sha256_file(destination), artifact_digest):
|
||||
raise UploadError(
|
||||
"ARTIFACT_STORE_CONFLICT", "Stored artifact failed integrity checks"
|
||||
)
|
||||
temporary.unlink()
|
||||
else:
|
||||
os.replace(temporary, destination)
|
||||
artifact_owner_id = metadata.get("owner_id")
|
||||
project_id = metadata.get("project_id")
|
||||
if isinstance(artifact_owner_id, str) and isinstance(project_id, str):
|
||||
provenance = destination.with_suffix(".access.json")
|
||||
grants: dict[str, object] = {}
|
||||
if provenance.exists():
|
||||
try:
|
||||
loaded = json.loads(provenance.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, dict):
|
||||
grants = {
|
||||
key: value
|
||||
for key, value in loaded.items()
|
||||
if isinstance(key, str)
|
||||
and isinstance(value, list)
|
||||
and all(isinstance(item, str) for item in value)
|
||||
}
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
raise UploadError(
|
||||
"ARTIFACT_STORE_CONFLICT", "Stored artifact access data is invalid"
|
||||
) from None
|
||||
existing_projects = grants.get(artifact_owner_id, [])
|
||||
projects = (
|
||||
set(existing_projects)
|
||||
if isinstance(existing_projects, list)
|
||||
and all(isinstance(item, str) for item in existing_projects)
|
||||
else set()
|
||||
)
|
||||
projects.add(project_id)
|
||||
grants[artifact_owner_id] = sorted(projects)
|
||||
grants["media_type"] = metadata.get("media_type")
|
||||
_write_json_atomically(provenance, grants)
|
||||
for path in directory.iterdir():
|
||||
if path.is_file() and not path.is_symlink():
|
||||
path.unlink()
|
||||
directory.rmdir()
|
||||
return ArtifactReceipt(
|
||||
sha256=artifact_digest,
|
||||
byte_length=byte_length,
|
||||
media_type=str(metadata.get("media_type", "application/octet-stream")),
|
||||
)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
def open_artifact(
|
||||
self,
|
||||
sha256: str,
|
||||
*,
|
||||
owner_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
) -> BinaryIO:
|
||||
if not re.fullmatch(r"[a-f0-9]{64}", sha256):
|
||||
raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist") from None
|
||||
path = self.artifact_root / sha256[:2] / sha256
|
||||
if (
|
||||
path.is_symlink()
|
||||
or not path.is_file()
|
||||
or not secrets.compare_digest(_sha256_file(path), sha256)
|
||||
):
|
||||
raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist")
|
||||
if owner_id is not None or project_id is not None:
|
||||
access_path = path.with_suffix(".access.json")
|
||||
try:
|
||||
if access_path.is_symlink() or not access_path.is_file():
|
||||
raise OSError("artifact access metadata is unavailable")
|
||||
access = json.loads(access_path.read_text(encoding="utf-8"))
|
||||
projects = access.get(owner_id) if isinstance(access, dict) and owner_id else None
|
||||
if owner_id is None and project_id is not None and isinstance(access, dict):
|
||||
projects = [
|
||||
granted_project
|
||||
for owner, owner_projects in access.items()
|
||||
if owner != "media_type" and isinstance(owner_projects, list)
|
||||
for granted_project in owner_projects
|
||||
]
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
projects = None
|
||||
if not isinstance(projects, list) or project_id not in projects:
|
||||
raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist")
|
||||
return path.open("rb")
|
||||
|
||||
def artifact_media_type_for_project(self, sha256: str, *, project_id: str) -> str:
|
||||
if not re.fullmatch(r"[a-f0-9]{64}", sha256):
|
||||
raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist")
|
||||
path = self.artifact_root / sha256[:2] / f"{sha256}.access.json"
|
||||
try:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise OSError("artifact access metadata is unavailable")
|
||||
access = json.loads(path.read_text(encoding="utf-8"))
|
||||
grants = (
|
||||
[
|
||||
project
|
||||
for owner, projects in access.items()
|
||||
if owner != "media_type" and isinstance(projects, list)
|
||||
for project in projects
|
||||
]
|
||||
if isinstance(access, dict)
|
||||
else []
|
||||
)
|
||||
media_type = access.get("media_type") if isinstance(access, dict) else None
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist") from None
|
||||
if project_id not in grants or not isinstance(media_type, str):
|
||||
raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist")
|
||||
return media_type
|
||||
|
||||
def artifact_media_type(self, sha256: str, *, owner_id: str, project_id: str) -> str:
|
||||
path = self.artifact_root / sha256[:2] / f"{sha256}.access.json"
|
||||
try:
|
||||
access = json.loads(path.read_text(encoding="utf-8"))
|
||||
projects = access.get(owner_id) if isinstance(access, dict) else None
|
||||
media_type = access.get("media_type") if isinstance(access, dict) else None
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist") from None
|
||||
if (
|
||||
not isinstance(projects, list)
|
||||
or project_id not in projects
|
||||
or not isinstance(media_type, str)
|
||||
):
|
||||
raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist")
|
||||
return media_type
|
||||
|
||||
def save_extraction(
|
||||
self,
|
||||
extraction: ExtractionRecord,
|
||||
*,
|
||||
owner_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
) -> None:
|
||||
if extraction_digest(extraction) != extraction.extraction_id:
|
||||
raise UploadError("EXTRACTION_HASH_MISMATCH", "Extraction identity is invalid")
|
||||
digest = extraction.extraction_id
|
||||
directory = self.extraction_root / digest[:2]
|
||||
directory.mkdir(exist_ok=True, mode=0o700)
|
||||
if directory.is_symlink():
|
||||
raise UploadError("EXTRACTION_STORE_UNAVAILABLE", "Extraction storage is unavailable")
|
||||
path = directory / f"{digest}.json"
|
||||
with self._lock:
|
||||
stored: dict[str, object] = {
|
||||
"owner_id": owner_id,
|
||||
"project_ids": [project_id] if project_id is not None else [],
|
||||
"record": extraction.model_dump(mode="json"),
|
||||
}
|
||||
if path.exists():
|
||||
if path.is_symlink():
|
||||
raise UploadError("EXTRACTION_STORE_CONFLICT", "Stored extraction is invalid")
|
||||
try:
|
||||
existing = json.loads(path.read_bytes())
|
||||
except (OSError, ValueError) as error:
|
||||
raise UploadError(
|
||||
"EXTRACTION_STORE_CONFLICT", "Stored extraction is invalid"
|
||||
) from error
|
||||
if (
|
||||
not isinstance(existing, dict)
|
||||
or existing.get("owner_id") != owner_id
|
||||
or existing.get("record") != stored["record"]
|
||||
):
|
||||
raise UploadError("EXTRACTION_STORE_CONFLICT", "Stored extraction is invalid")
|
||||
project_ids = existing.get("project_ids", [])
|
||||
if not isinstance(project_ids, list) or not all(
|
||||
isinstance(item, str) for item in project_ids
|
||||
):
|
||||
project_ids = []
|
||||
if project_id is not None and project_id not in project_ids:
|
||||
project_ids.append(project_id)
|
||||
stored["project_ids"] = sorted(set(project_ids))
|
||||
content = json.dumps(
|
||||
stored,
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
_write_json_atomically(path, json.loads(content))
|
||||
|
||||
def get_extraction(
|
||||
self,
|
||||
extraction_id: str,
|
||||
*,
|
||||
owner_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
) -> ExtractionRecord | None:
|
||||
if not re.fullmatch(r"[a-f0-9]{64}", extraction_id):
|
||||
return None
|
||||
path = self.extraction_root / extraction_id[:2] / f"{extraction_id}.json"
|
||||
if path.is_symlink() or not path.is_file():
|
||||
return None
|
||||
try:
|
||||
stored = json.loads(path.read_bytes())
|
||||
if (
|
||||
not isinstance(stored, dict)
|
||||
or (owner_id is not None and stored.get("owner_id") != owner_id)
|
||||
or (owner_id is None and project_id is None)
|
||||
or (
|
||||
project_id is not None
|
||||
and project_id not in stored.get("project_ids", [])
|
||||
and stored.get("project_id") != project_id
|
||||
)
|
||||
):
|
||||
return None
|
||||
extraction = ExtractionRecord.model_validate_json(
|
||||
json.dumps(stored.get("record"), ensure_ascii=False), strict=True
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if extraction_digest(extraction) != extraction_id:
|
||||
return None
|
||||
return extraction
|
||||
Reference in new issue
Block a user