feat: implement GEWU service and local agent workflows
CI / quality (push) Canceled after 0s

This commit is contained in:
vasily committed 2026-10-14 19:21:35 +08:00
1 parent 4db3f45164
commit d25c04cdd2
175 files changed
+56243 -15

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""GEWU use cases."""
File diff suppressed because it is too large. Load diff
+52
View File
@@ -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: ...
+45
View File
@@ -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: ...
+23
View File
@@ -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]
+88
View File
@@ -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,
)
+75
View File
@@ -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: ...
+13
View File
@@ -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")
+177
View File
@@ -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: ...
+218
View File
@@ -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
+426
View File
@@ -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
+223
View File
@@ -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
+998
View File
@@ -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
+35
View File
@@ -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)
+652
View File
@@ -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