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 @@
"""Application integration tests."""
@@ -0,0 +1,29 @@
"""Terminate acceptance at a durable publication boundary for recovery tests."""
from __future__ import annotations
import os
import sys
from pathlib import Path
from gewu.domain.contracts import ProposalRevision
from tests.application.test_acceptance import ACTOR, _service
def main() -> None:
data_root = Path(sys.argv[1])
proposal_path = Path(sys.argv[2])
crash_stage = sys.argv[3]
proposal = ProposalRevision.model_validate_json(proposal_path.read_bytes(), strict=True)
def terminate(stage: str) -> None:
if stage == crash_stage:
os._exit(73)
service, *_ = _service(data_root, hook=terminate)
service.submit(proposal, actor=ACTOR)
raise RuntimeError(f"Acceptance completed without reaching crash stage {crash_stage}")
if __name__ == "__main__":
main()
+850
View File
@@ -0,0 +1,850 @@
import hashlib
import json
import subprocess
import sys
import threading
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pyotp
import pytest
from cryptography.fernet import Fernet
from gewu.adapters.git_repository import GitCanonicalRepository
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
from gewu.adapters.sqlite_search import SQLiteSearchIndex
from gewu.adapters.text_extractor import TextMarkdownExtractor
from gewu.application.acceptance import (
AcceptanceError,
AcceptanceService,
ActorContext,
)
from gewu.application.context import ValidationContext
from gewu.application.identity import Principal
from gewu.application.reading import CanonicalNodeReader
from gewu.application.search import CanonicalSearchService
from gewu.application.serialization import safe_yaml_record
from gewu.application.uploads import LocalArtifactStore
from gewu.domain.contracts import (
AcceptanceManifest,
EdgeEndpoint,
ExtractionRecord,
KnowledgeNode,
ProposalOperation,
ProposalRevision,
ReliabilityGrade,
Scope,
SearchRequest,
SourceRevisionRecord,
TypedEdge,
)
from gewu.domain.validation import assertion_digest, evidence_digest, proposal_payload_digest
from tests.domain.proposal_fixtures import (
EXTRACTION,
NODE,
POLICIES,
PROJECTS,
SOURCE_REVISION,
proposal,
)
ACTOR = ActorContext(principal_id="agent-pilot", credential_id="agent-key-1")
SOURCE_BYTES = b"The installation hosted 400 visitors during the 2026 season."
class SimulatedProcessCrash(BaseException):
pass
def _service(
data_root: Path,
*,
hook: Callable[[str], None] = lambda _stage: None,
authorized: bool = True,
) -> tuple[
AcceptanceService,
GitCanonicalRepository,
LocalArtifactStore,
SourceRevisionRecord,
ExtractionRecord,
]:
artifact_store = LocalArtifactStore(data_root / "private-data")
upload = artifact_store.begin(
filename="pilot.md",
media_type="text/markdown",
byte_length=len(SOURCE_BYTES),
expected_sha256=hashlib.sha256(SOURCE_BYTES).hexdigest(),
now=100,
)
artifact_store.put_chunk(
upload.upload_id,
offset=0,
content=SOURCE_BYTES,
chunk_sha256=hashlib.sha256(SOURCE_BYTES).hexdigest(),
now=101,
)
artifact_store.finalize(upload.upload_id, now=102)
source = SOURCE_REVISION.model_copy(
update={
"artifact_sha256": hashlib.sha256(SOURCE_BYTES).hexdigest(),
"byte_length": len(SOURCE_BYTES),
}
)
extraction = EXTRACTION
repository = GitCanonicalRepository(data_root / "private-data" / "knowledge.git")
context = ValidationContext(
projects=PROJECTS,
policies=POLICIES,
sources={},
extractions={extraction.extraction_id: extraction},
existing_nodes={},
)
service = AcceptanceService(
repository=repository,
artifact_store=artifact_store,
workflow_root=data_root / "private-data" / "staging",
context_provider=lambda: context,
actor_is_current=lambda _actor: authorized,
failure_hook=hook,
)
return service, repository, artifact_store, source, extraction
def _proposal(repository: GitCanonicalRepository, source: SourceRevisionRecord) -> ProposalRevision:
candidate = proposal(source=source, base_commit=repository.head())
return candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)})
def test_acceptance_commits_records_and_retry_returns_original_receipt(tmp_path: Path) -> None:
service, repository, _artifact_store, source, _extraction = _service(tmp_path)
candidate = _proposal(repository, source)
result = service.submit(candidate, actor=ACTOR)
assert result.validation.eligible_for_auto_accept
assert result.receipt is not None
assert result.receipt.state == "accepted"
assert result.receipt.commit_id == repository.head()
assert result.receipt.accepted_node_ids == (NODE.node_id,)
before_retry = repository.head()
retry = service.submit(candidate, actor=ACTOR)
assert retry.receipt == result.receipt
assert repository.head() == before_retry
status = service.proposal_status(candidate.proposal_id, candidate.revision, actor=ACTOR)
assert status.lifecycle == "accepted"
assert status.canonical_revision == result.receipt.commit_id
assert status.receipt == result.receipt
with pytest.raises(AcceptanceError) as accepted_withdrawal:
service.withdraw_proposal(candidate.proposal_id, candidate.revision, actor=ACTOR)
assert accepted_withdrawal.value.code == "PROPOSAL_ALREADY_ACCEPTED"
with pytest.raises(AcceptanceError) as unauthorized:
service.proposal_status(
candidate.proposal_id,
candidate.revision,
actor=ActorContext(principal_id="agent-other", credential_id="other-key"),
)
assert unauthorized.value.code == "PROPOSAL_NOT_FOUND"
changed_node = NODE.model_copy(update={"title": "Changed after acceptance"})
changed = proposal(node=changed_node, source=source, base_commit=candidate.base_commit)
with pytest.raises(AcceptanceError) as error:
service.submit(changed, actor=ACTOR)
assert error.value.code == "IDEMPOTENCY_CONFLICT"
def test_policy_exception_requires_exact_owner_decision_in_manifest(tmp_path: Path) -> None:
service, repository, _artifact_store, source, extraction = _service(tmp_path)
identities = SQLiteIdentityStore(
tmp_path / "owner-identity.sqlite3", owner_encryption_key=Fernet.generate_key()
)
secret = pyotp.random_base32()
identities.provision_owner(
principal_id="project-owner",
password="correct horse battery staple",
totp_secret=secret,
project_ids=("dome-performance", "shared"),
)
projects = {
**PROJECTS,
"dome-performance": PROJECTS["dome-performance"].model_copy(
update={"owners": ("project-owner",)}
),
"shared": PROJECTS["dome-performance"].model_copy(
update={
"project_id": "shared",
"title": "Shared knowledge",
"owners": ("project-owner",),
"autonomy_policy_id": "shared",
}
),
}
policies = {
**POLICIES,
"dome-policy": POLICIES["dome-policy"].model_copy(
update={"automatic_node_types": ("finding",)}
),
}
context = ValidationContext(
projects=projects,
policies=policies,
sources={},
extractions={extraction.extraction_id: extraction},
existing_nodes={},
)
service.context_provider = lambda: context
service.owner_challenge_issuer = identities.issue_owner_challenge
service.owner_decision_lookup = identities.authorized_owner_challenges
candidate = _proposal(repository, source)
held = service.submit(candidate, actor=ACTOR)
assert held.validation.status == "needs-review"
assert held.receipt is None
assert len(held.owner_challenge_ids) == 1
challenge = identities.get_owner_challenge(
held.owner_challenge_ids[0], principal_id="project-owner"
)
assert challenge is not None
assert challenge["diff_json"]
owner_session = identities.authenticate_owner(
principal_id="project-owner",
password="correct horse battery staple",
totp_code=pyotp.TOTP(secret).now(),
challenge_id=held.owner_challenge_ids[0],
audience="gewu-acceptance-test",
)
assert owner_session is not None
owner_principal = identities.authenticate_owner_session(
owner_session.session_token,
challenge_id=held.owner_challenge_ids[0],
audience="gewu-acceptance-test",
)
assert owner_principal is not None
decision = identities.decide_owner_challenge(
challenge_id=held.owner_challenge_ids[0],
principal_id="project-owner",
proposal_revision=candidate.revision,
payload_digest=candidate.payload_digest,
diff_digest=challenge["diff_digest"],
decision="authorize",
rationale="Reviewed the exact file diff and approve this policy exception.",
owner_session_id=owner_principal.credential_id.rpartition(":")[2],
audience="gewu-acceptance-test",
)
assert decision["status"] == "authorized"
original_context = context
changed_policies = {
**policies,
"dome-policy": policies["dome-policy"].model_copy(update={"policy_version": 2}),
}
context = ValidationContext(
projects=projects,
policies=changed_policies,
sources={},
extractions={extraction.extraction_id: extraction},
existing_nodes={},
)
stale_approval = service.submit(candidate, actor=ACTOR)
assert stale_approval.receipt is None
assert stale_approval.validation.status == "needs-review"
assert repository.head() == candidate.base_commit
context = original_context
accepted = service.submit(candidate, actor=ACTOR)
assert accepted.receipt is not None
assert accepted.validation.status == "needs-review"
raw_manifest = repository.read_file(
accepted.receipt.commit_id,
service._manifest_path(candidate.proposal_id, candidate.revision),
)
assert raw_manifest is not None
manifest = safe_yaml_record(raw_manifest, AcceptanceManifest)
assert len(manifest.owner_decisions) == 1
assert manifest.owner_decisions[0].challenge_id == held.owner_challenge_ids[0]
def test_contradiction_bundle_publishes_both_claims_and_their_typed_edge(
tmp_path: Path,
) -> None:
content = (
b"Field report: The seasonal audience count was 400.\n\n"
b"Vendor report: The seasonal audience count was 450.\n"
)
digest = hashlib.sha256(content).hexdigest()
artifact_store = LocalArtifactStore(tmp_path / "private-data")
upload = artifact_store.begin(
filename="conflicting-reports.md",
media_type="text/markdown",
byte_length=len(content),
expected_sha256=digest,
now=100,
)
artifact_store.put_chunk(
upload.upload_id,
offset=0,
content=content,
chunk_sha256=digest,
now=101,
)
artifact_store.finalize(upload.upload_id, now=102)
source = SOURCE_REVISION.model_copy(
update={"artifact_sha256": digest, "byte_length": len(content)}
)
extraction = TextMarkdownExtractor().extract(source.revision_id, content, "text/markdown")
def supported_node(
node_id: str, claim_id: str, quote: str, title: str, value: int
) -> KnowledgeNode:
block = next(item for item in extraction.blocks if quote in item.normalized_text)
start = block.normalized_text.index(quote)
evidence = (
NODE.claims[0]
.evidence[0]
.model_copy(
update={
"source_revision_id": source.revision_id,
"extraction_id": extraction.extraction_id,
"assertion_digest": assertion_digest(quote),
"quote": quote,
"locator": NODE.claims[0]
.evidence[0]
.locator.model_copy(
update={
"block_id": block.block_id,
"start": start,
"end": start + len(quote),
}
),
}
)
)
assessment = NODE.claims[0].assessment.model_copy(
update={
"assertion_digest": assertion_digest(quote),
"evidence_digests": (evidence_digest(evidence),),
}
)
claim = NODE.claims[0].model_copy(
update={
"claim_id": claim_id,
"text": quote,
"evidence": (evidence,),
"assessment": assessment,
}
)
return NODE.model_copy(
update={
"node_id": node_id,
"title": title,
"summary": quote,
"claims": (claim,),
"value": value,
}
)
first = supported_node(
"dome:count-field-report",
"dome:claim-count-400",
"The seasonal audience count was 400.",
"Field count",
400,
)
second = supported_node(
"dome:count-vendor-report",
"dome:claim-count-450",
"The seasonal audience count was 450.",
"Vendor count",
450,
)
edge = TypedEdge(
edge_id="dome:counts-conflict",
edge_type="contradicts",
source=EdgeEndpoint(kind="node", id=first.node_id),
target=EdgeEndpoint(kind="node", id=second.node_id),
conditions="Both reports refer to the same seasonal audience count.",
interpretation_rationale="The source-backed counts differ for the same reporting period.",
)
policies = {
**POLICIES,
"dome-policy": POLICIES["dome-policy"].model_copy(
update={"automatic_edge_types": ("contradicts",)}
),
}
repository = GitCanonicalRepository(tmp_path / "private-data" / "knowledge.git")
context = ValidationContext(
projects=PROJECTS,
policies=policies,
sources={},
extractions={extraction.extraction_id: extraction},
existing_nodes={},
)
service = AcceptanceService(
repository=repository,
artifact_store=artifact_store,
workflow_root=tmp_path / "private-data" / "staging",
context_provider=lambda: context,
actor_is_current=lambda _actor: True,
)
base = proposal(node=None, source=source, base_commit=repository.head())
candidate = base.model_copy(
update={
"operations": (
*base.operations,
ProposalOperation(operation="upsert_node", node=first),
ProposalOperation(operation="upsert_node", node=second),
ProposalOperation(operation="add_edge", edge=edge),
),
"payload_digest": "0" * 64,
}
)
candidate = candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)})
result = service.submit(candidate, actor=ACTOR)
assert result.validation.status == "eligible", result.validation.findings
assert result.receipt is not None
assert result.receipt.accepted_node_ids == (first.node_id, second.node_id)
for node in (first, second):
stored = repository.read_file(
result.receipt.commit_id,
f"projects/{hashlib.sha256(node.project_id.encode()).hexdigest()}/nodes/"
f"{hashlib.sha256(node.node_id.encode()).hexdigest()}.yaml",
)
assert stored is not None
assert safe_yaml_record(stored, KnowledgeNode).claims[0].text == node.claims[0].text
stored_edge = repository.read_file(
result.receipt.commit_id,
f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml",
)
assert stored_edge is not None
assert safe_yaml_record(stored_edge, TypedEdge) == edge
def test_independent_acceptance_services_exclude_a_concurrent_writer(tmp_path: Path) -> None:
entered_commit = threading.Event()
release_commit = threading.Event()
def hold_before_commit(stage: str) -> None:
if stage == "before_commit":
entered_commit.set()
assert release_commit.wait(timeout=5)
service, repository, artifact_store, source, _extraction = _service(
tmp_path, hook=hold_before_commit
)
second_context = ValidationContext(
projects=PROJECTS,
policies=POLICIES,
sources={},
extractions={EXTRACTION.extraction_id: EXTRACTION},
existing_nodes={},
)
second_service = AcceptanceService(
repository=repository,
artifact_store=artifact_store,
workflow_root=tmp_path / "private-data" / "staging",
context_provider=lambda: second_context,
actor_is_current=lambda _actor: True,
)
first = _proposal(repository, source).model_copy(update={"proposal_id": "proposal-writer-a"})
second = _proposal(repository, source).model_copy(update={"proposal_id": "proposal-writer-b"})
with ThreadPoolExecutor(max_workers=1) as workers:
pending = workers.submit(service.submit, first, actor=ACTOR)
try:
assert entered_commit.wait(timeout=5)
with pytest.raises(AcceptanceError) as busy:
second_service.submit(second, actor=ACTOR)
assert busy.value.code == "BUSY"
finally:
release_commit.set()
accepted = pending.result(timeout=5)
assert accepted.receipt is not None
assert repository.head() == accepted.receipt.commit_id
assert second_service.receipt(second.proposal_id, second.revision) is None
def test_acceptance_publishes_and_reads_evidence_backed_assumptions(tmp_path: Path) -> None:
service, repository, _artifact_store, source, extraction = _service(tmp_path)
original_claim = NODE.claims[0]
statement = "The installation's seasonal count is suitable for comparison."
statement_digest = assertion_digest(statement)
evidence = original_claim.evidence[0].model_copy(
update={
"assertion_path": "assumptions[0].text",
"assertion_digest": statement_digest,
}
)
claim = original_claim.model_copy(
update={
"claim_id": "dome:assumption-seasonal-comparison",
"text": statement,
"evidence": (evidence,),
"assessment": original_claim.assessment.model_copy(
update={
"assertion_digest": statement_digest,
"evidence_digests": (evidence_digest(evidence),),
}
),
}
)
node = NODE.model_copy(update={"assumptions": (claim,)})
candidate = proposal(node=node, source=source, base_commit=repository.head())
candidate = candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)})
result = service.submit(
candidate,
actor=ACTOR,
staged_extractions={extraction.extraction_id: extraction},
)
assert result.receipt is not None
assert (
repository.read_file(
result.receipt.commit_id, f"extractions/{extraction.extraction_id}.json"
)
is not None
)
reader = CanonicalNodeReader(repository)
principal = Principal(
"reader", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"})
)
read = reader.get_node(node.project_id, node.node_id, principal=principal)
stored = read.data[0]
assert stored["assumptions"][0]["text"] == statement
assert any(item.assertion_digest == statement_digest for item in read.citations)
def test_reference_only_source_registers_without_an_artifact(tmp_path: Path) -> None:
service, repository, _artifacts, _source, _extraction = _service(tmp_path)
reference = SourceRevisionRecord(
source_id="dome:missing-paper",
revision_id="dome:missing-paper:r1",
media_type="application/octet-stream",
byte_length=0,
captured_at=SOURCE_REVISION.captured_at,
original_url="https://research.example/missing-paper",
title="Paper whose archive is unavailable",
license="unspecified",
source_type="paper",
reliability_grade=ReliabilityGrade.MEDIUM,
ingested_for=("dome-performance",),
archive_status="capture-failed",
)
candidate = ProposalRevision(
proposal_id="proposal-reference-only",
revision=1,
initiating_project_id="dome-performance",
scope=proposal().scope,
base_commit=repository.head(),
payload_digest="0" * 64,
proposer_id=ACTOR.principal_id,
run_id="run-reference-only",
operations=(ProposalOperation(operation="register_source", source=reference),),
)
candidate = candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)})
result = service.submit(candidate, actor=ACTOR)
assert result.receipt is not None
assert result.receipt.state == "accepted"
paths = repository.list_files(repository.head(), "sources")
stored = next(repository.read_file(repository.head(), path) for path in paths)
assert stored is not None
assert b"capture-failed" in stored
assert b"artifact_sha256: null" in stored
@pytest.mark.parametrize("crash_stage", ("after_ref_update", "before_receipt"))
def test_ref_update_before_receipt_is_reconciled_without_republishing(
tmp_path: Path, crash_stage: str
) -> None:
def fail_after_ref_update(stage: str) -> None:
if stage == crash_stage:
raise SimulatedProcessCrash
service, repository, _store, source, _extraction = _service(
tmp_path, hook=fail_after_ref_update
)
candidate = _proposal(repository, source)
with pytest.raises(SimulatedProcessCrash):
service.submit(candidate, actor=ACTOR)
accepted_head = repository.head()
recovered, _same_repo, _same_store, _same_source, _same_extraction = (
_service_without_new_artifact(service, repository, tmp_path, source)
)
recovered.recover()
receipt = recovered.receipt(candidate.proposal_id, candidate.revision)
assert receipt is not None
assert receipt.commit_id == accepted_head
assert repository.head() == accepted_head
status = recovered.proposal_status(candidate.proposal_id, candidate.revision, actor=ACTOR)
assert status.lifecycle == "accepted"
assert status.receipt == receipt
retry = recovered.submit(candidate, actor=ACTOR)
assert retry.receipt == receipt
@pytest.mark.parametrize("crash_stage", ("after_ref_update", "before_receipt"))
def test_os_process_termination_recovers_publication_after_restart(
tmp_path: Path, crash_stage: str
) -> None:
service, _repository, _store, source, _extraction = _service(tmp_path)
candidate = _proposal(service.repository, source)
proposal_path = tmp_path / "candidate.json"
proposal_path.write_bytes(candidate.model_dump_json().encode("utf-8"))
crashed = subprocess.run(
[
sys.executable,
"-m",
"tests.application.crash_acceptance_worker",
str(tmp_path),
str(proposal_path),
crash_stage,
],
cwd=Path(__file__).parents[2],
capture_output=True,
check=False,
timeout=60,
)
assert crashed.returncode == 73, crashed.stderr.decode("utf-8", errors="replace")
# Construct fresh adapters as a new service process would, then recover from durable state.
recovered, repository, _store, _source, _extraction = _service(tmp_path)
committed_head = repository.head()
recovered.recover()
receipt = recovered.receipt(candidate.proposal_id, candidate.revision)
assert receipt is not None
assert receipt.commit_id == committed_head
status = recovered.proposal_status(candidate.proposal_id, candidate.revision, actor=ACTOR)
assert status.lifecycle == "accepted"
assert status.receipt == receipt
# The derived index was absent at the crash. Its first read after recovery must
# rebuild from the committed revision and retain the accepted claim's citation.
index = SQLiteSearchIndex(tmp_path / "private-data" / "search.sqlite3", repository)
assert index.diagnostic_status()[0] is None
reader = CanonicalSearchService(
index=index,
project_ids_provider=lambda: PROJECTS.keys(),
canonical_head=repository.head,
)
search_result = reader.search(
SearchRequest(
query="visitors",
scope=Scope(projects=("dome-performance",), include_shared=False),
budget_tokens=5000,
),
principal=Principal(
"recovery-reader",
"recovery-reader-key",
frozenset({"viewer"}),
frozenset({"dome-performance"}),
),
)
assert search_result.data
indexed_node = search_result.data[0]["node"]
assert isinstance(indexed_node, dict)
assert indexed_node["node_id"] == NODE.node_id
assert search_result.citations
retry = recovered.submit(candidate, actor=ACTOR)
assert retry.receipt == receipt
assert repository.head() == committed_head
def test_prepublication_crash_leaves_proposal_staged_and_retryable(tmp_path: Path) -> None:
def fail_before_commit(stage: str) -> None:
if stage == "before_commit":
raise SimulatedProcessCrash
service, repository, _store, source, _extraction = _service(tmp_path, hook=fail_before_commit)
candidate = _proposal(repository, source)
base = repository.head()
with pytest.raises(SimulatedProcessCrash):
service.submit(candidate, actor=ACTOR)
assert repository.head() == base
recovered, *_ = _service_without_new_artifact(service, repository, tmp_path, source)
recovered.recover()
status = recovered.proposal_status(candidate.proposal_id, candidate.revision, actor=ACTOR)
assert status.lifecycle == "staged"
retry = recovered.submit(candidate, actor=ACTOR)
assert retry.receipt is not None
assert repository.head() == retry.receipt.commit_id
def test_unrelated_submission_waits_until_pending_publication_is_reconciled(
tmp_path: Path,
) -> None:
crash_pending_writer = True
def fail_after_ref_update(stage: str) -> None:
nonlocal crash_pending_writer
if stage == "after_ref_update" and crash_pending_writer:
crash_pending_writer = False
raise SimulatedProcessCrash
service, repository, _store, source, _extraction = _service(
tmp_path, hook=fail_after_ref_update
)
pending = _proposal(repository, source)
with pytest.raises(SimulatedProcessCrash):
service.submit(pending, actor=ACTOR)
committed_head = repository.head()
journal_path = sorted((tmp_path / "private-data/staging/journal").glob("*/*.json"))[-1]
journal_entry = json.loads(journal_path.read_text(encoding="utf-8"))
expected_manifest_hash = journal_entry["manifest_sha256"]
journal_entry["manifest_sha256"] = "0" * 64
journal_path.write_text(json.dumps(journal_entry), encoding="utf-8")
followup_node = NODE.model_copy(
update={"node_id": "dome:follow-up-finding", "title": "Follow-up visitor finding"}
)
followup = proposal(
node=followup_node,
source=source,
base_commit=committed_head,
).model_copy(update={"proposal_id": "proposal-pilot-2"})
followup = followup.model_copy(update={"payload_digest": proposal_payload_digest(followup)})
with pytest.raises(AcceptanceError) as blocked:
service.submit(followup, actor=ACTOR)
assert blocked.value.code == "RECOVERY_REQUIRED"
assert repository.head() == committed_head
assert not service._proposal_path(followup.proposal_id, followup.revision).exists()
journal_entry["manifest_sha256"] = expected_manifest_hash
journal_path.write_text(json.dumps(journal_entry), encoding="utf-8")
service.recover()
pending_receipt = service.receipt(pending.proposal_id, pending.revision)
assert pending_receipt is not None
assert pending_receipt.commit_id == committed_head
completed = service.submit(followup, actor=ACTOR)
assert completed.receipt is not None
assert completed.receipt.commit_id == repository.head()
assert repository.head() != committed_head
def test_proposal_withdrawal_is_idempotent_and_prevents_later_submission(tmp_path: Path) -> None:
service, repository, _store, source, _extraction = _service(tmp_path)
initial_head = repository.head()
candidate = _proposal(repository, source).model_copy(update={"base_commit": "f" * 40})
candidate = candidate.model_copy(update={"payload_digest": "0" * 64})
candidate = candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)})
evaluated = service.submit(candidate, actor=ACTOR)
assert evaluated.validation.status == "conflict"
assert evaluated.receipt is None
withdrawn = service.withdraw_proposal(candidate.proposal_id, candidate.revision, actor=ACTOR)
assert withdrawn.lifecycle == "withdrawn"
assert (
service.withdraw_proposal(candidate.proposal_id, candidate.revision, actor=ACTOR)
== withdrawn
)
assert (
service.proposal_status(candidate.proposal_id, candidate.revision, actor=ACTOR) == withdrawn
)
with pytest.raises(AcceptanceError) as error:
service.submit(candidate, actor=ACTOR)
assert error.value.code == "PROPOSAL_WITHDRAWN"
assert repository.head() == initial_head
def _service_without_new_artifact(
old_service: AcceptanceService,
repository: GitCanonicalRepository,
data_root: Path,
source: SourceRevisionRecord,
) -> tuple[
AcceptanceService,
GitCanonicalRepository,
LocalArtifactStore,
SourceRevisionRecord,
ExtractionRecord,
]:
from tests.domain.proposal_fixtures import EXTRACTION
store = old_service.artifact_store
context = ValidationContext(
projects=PROJECTS,
policies=POLICIES,
sources={},
extractions={EXTRACTION.extraction_id: EXTRACTION},
existing_nodes={},
)
service = AcceptanceService(
repository=repository,
artifact_store=store,
workflow_root=data_root / "private-data" / "staging",
context_provider=lambda: context,
actor_is_current=lambda _actor: True,
)
return service, repository, store, source, EXTRACTION
def test_changed_authority_blocks_publication(tmp_path: Path) -> None:
service, repository, _store, source, _extraction = _service(tmp_path, authorized=False)
candidate = _proposal(repository, source)
with pytest.raises(AcceptanceError) as error:
service.submit(candidate, actor=ACTOR)
assert error.value.code == "UNAUTHORIZED"
assert repository.head() == candidate.base_commit
def test_revoked_key_during_validation_is_rechecked_before_publication(tmp_path: Path) -> None:
service, repository, _store, source, _extraction = _service(tmp_path)
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
token = identities.provision(
credential_id="agent-key-1",
principal_id="agent-pilot",
roles=("agent",),
project_ids=("dome-performance", "shared"),
)
principal = identities.authenticate(token)
assert principal is not None
actor = ActorContext(
principal_id=principal.principal_id,
credential_id=principal.credential_id,
roles=principal.roles,
project_ids=principal.project_ids,
)
context = ValidationContext(
projects=PROJECTS,
policies=POLICIES,
sources={},
extractions={_extraction.extraction_id: _extraction},
existing_nodes={},
)
context_reads = 0
def context_provider() -> ValidationContext:
nonlocal context_reads
context_reads += 1
if context_reads == 2:
assert identities.revoke("agent-key-1")
return context
service.context_provider = context_provider
service.actor_is_current = lambda candidate: identities.is_current(
candidate.principal_id,
candidate.credential_id,
roles=candidate.roles,
project_ids=candidate.project_ids,
)
candidate = _proposal(repository, source)
with pytest.raises(AcceptanceError) as error:
service.submit(candidate, actor=actor)
assert error.value.code == "POLICY_CHANGED"
assert repository.head() == candidate.base_commit
status = service.proposal_status(candidate.proposal_id, candidate.revision, actor=actor)
assert status.lifecycle == "evaluated"
assert status.receipt is None
+305
View File
@@ -0,0 +1,305 @@
from __future__ import annotations
import hashlib
import sqlite3
from pathlib import Path
import pytest
import yaml
from gewu.adapters.git_repository import GitCanonicalRepository
from gewu.adapters.sqlite_search import SearchIndexError, SQLiteSearchIndex
from gewu.adapters.sqlite_vectors import SQLiteVectorIndex
from gewu.application.identity import Principal
from gewu.application.search import CanonicalSearchService, HybridSearchIndex, SearchError
from gewu.domain.contracts import (
Confidence,
EdgeEndpoint,
NodeType,
Scope,
SearchRequest,
TypedEdge,
)
from tests.domain.proposal_fixtures import NODE
class FixtureEmbeddings:
dimensions = 2
model_id = "fixture-e5@v1"
artifact_sha256 = "b" * 64
def embed_queries(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]:
return tuple((1.0, 0.0) for _ in texts)
def embed_passages(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]:
return tuple(
(1.0, 0.0) if text.startswith("Semantic match") else (0.0, 1.0) for text in texts
)
def _record_path(node_id: str, project_id: str) -> str:
project_key = hashlib.sha256(project_id.encode()).hexdigest()
node_key = hashlib.sha256(node_id.encode()).hexdigest()
return f"projects/{project_key}/nodes/{node_key}.yaml"
def _repository(tmp_path: Path) -> GitCanonicalRepository:
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
nodes = [
NODE.model_copy(
update={
"node_id": f"dome:alpha-{number:02}",
"title": f"Alpha candidate {number:02}",
"summary": f"Alpha lexical candidate {number:02}.",
"node_type": NodeType.FINDING,
"confidence": Confidence.MEDIUM,
}
)
for number in range(12)
]
nodes.extend(
(
NODE.model_copy(
update={
"node_id": "dome:semantic-match",
"title": "Semantic match for the intent",
"summary": "A close meaning with no shared query word.",
"node_type": NodeType.TECHNIQUE,
"confidence": Confidence.HIGH,
}
),
NODE.model_copy(
update={
"node_id": "dome:graph-related",
"title": "Connected nonlexical technique",
"summary": "A related result found through one typed edge.",
"node_type": NodeType.TECHNIQUE,
"confidence": Confidence.MEDIUM,
}
),
NODE.model_copy(
update={
"node_id": "video:semantic-match",
"project_id": "interactive-video",
"title": "Semantic match in another project",
"summary": "Must remain outside the requested project scope.",
"node_type": NodeType.TECHNIQUE,
"confidence": Confidence.HIGH,
}
),
)
)
edges = (
TypedEdge(
edge_id="dome:search-expansion-edge",
edge_type="compared_with",
source=EdgeEndpoint(kind="node", id="dome:alpha-00"),
target=EdgeEndpoint(kind="node", id="dome:graph-related"),
interpretation_rationale="Both notes inform the same audience-attendance question.",
),
TypedEdge(
edge_id="dome:search-cross-project-edge",
edge_type="compared_with",
source=EdgeEndpoint(kind="node", id="dome:alpha-00"),
target=EdgeEndpoint(kind="node", id="video:semantic-match"),
interpretation_rationale="Cross-project edge must not widen search scope.",
),
)
files = {
_record_path(node.node_id, node.project_id): yaml.safe_dump(
node.model_dump(mode="json")
).encode()
for node in nodes
}
files.update(
{
f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml": yaml.safe_dump(
edge.model_dump(mode="json")
).encode()
for edge in edges
}
)
repository.publish_files(
expected_head=repository.head(),
files=files,
message="Add hybrid search fixtures",
index_directory=tmp_path / "publishing",
)
return repository
def test_hybrid_search_fuses_ranks_and_preserves_scope_and_filters(
tmp_path: Path, monkeypatch
) -> None:
from gewu.application import search as search_module
monkeypatch.setattr(search_module, "_VECTOR_CANDIDATE_LIMIT", 1)
repository = _repository(tmp_path)
search_path = tmp_path / "search.sqlite3"
lexical = SQLiteSearchIndex(search_path, repository)
vectors = SQLiteVectorIndex(search_path)
hybrid = HybridSearchIndex(
lexical=lexical,
vectors=vectors,
embeddings=FixtureEmbeddings(),
)
lexical.configure_vector_generation(
extension_loader=vectors.load_extension,
builder=hybrid.build_vector_generation,
is_current=hybrid.vector_generation_is_current,
)
service = CanonicalSearchService(
index=hybrid,
project_ids_provider=lambda: ("dome-performance", "interactive-video"),
canonical_head=repository.head,
)
principal = Principal(
"reader", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"})
)
request = SearchRequest(
query="alpha",
scope=Scope(projects=("dome-performance",), include_shared=False),
limit=20,
budget_tokens=50_000,
)
response = service.search(request, principal=principal)
first_generation = vectors.active_generation()
assert first_generation is not None
assert lexical.path == vectors.path
assert first_generation.revision == response.index_revision
assert first_generation.node_count == 15
result_ids = [item["node"]["node_id"] for item in response.data]
assert response.retrieval_mode == "hybrid"
assert result_ids.index("dome:semantic-match") < result_ids.index("dome:alpha-11")
graph_hit = next(
item for item in response.data if item["node"]["node_id"] == "dome:graph-related"
)
assert graph_hit["retrieval_path"] == "graph_expansion"
assert graph_hit["expanded_from"] == "dome:alpha-00"
assert graph_hit["expansion_edge"]["edge_type"] == "compared_with"
assert not any(node_id.startswith("video:") for node_id in result_ids)
assert all("retrieval_score" in item for item in response.data)
first_page = service.search(request.model_copy(update={"limit": 5}), principal=principal)
assert first_page.next_cursor is not None
second_page = service.search(
request.model_copy(update={"limit": 5, "cursor": first_page.next_cursor}),
principal=principal,
)
first_ids = {item["node"]["node_id"] for item in first_page.data}
second_ids = {item["node"]["node_id"] for item in second_page.data}
assert len(first_ids) == len(second_ids) == 5
assert first_ids.isdisjoint(second_ids)
class ChangedModelEmbeddings(FixtureEmbeddings):
model_id = "fixture-e5@v2"
changed_hybrid = HybridSearchIndex(
lexical=lexical,
vectors=vectors,
embeddings=ChangedModelEmbeddings(),
)
changed_profile_service = CanonicalSearchService(
index=changed_hybrid,
project_ids_provider=lambda: ("dome-performance", "interactive-video"),
canonical_head=repository.head,
)
with pytest.raises(SearchError, match="cursor is invalid"):
changed_profile_service.search(
request.model_copy(update={"limit": 5, "cursor": first_page.next_cursor}),
principal=principal,
)
filtered = service.search(
request.model_copy(
update={"node_types": (NodeType.TECHNIQUE,), "confidences": (Confidence.HIGH,)}
),
principal=principal,
)
assert [item["node"]["node_id"] for item in filtered.data] == ["dome:semantic-match"]
assert filtered.retrieval_mode == "hybrid"
lexical.configure_vector_generation(
extension_loader=vectors.load_extension,
builder=changed_hybrid.build_vector_generation,
is_current=changed_hybrid.vector_generation_is_current,
)
changed_response = changed_profile_service.search(
request.model_copy(update={"limit": 5}), principal=principal
)
changed_generation = vectors.active_generation()
assert changed_response.retrieval_mode == "hybrid"
assert changed_generation is not None and changed_generation.model_id == "fixture-e5@v2"
assert vectors.generation(first_generation.generation_id) is not None
class FailingEmbeddings(FixtureEmbeddings):
model_id = "fixture-e5@failed-build"
def embed_passages(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]:
raise RuntimeError("fixture model failure")
failing_hybrid = HybridSearchIndex(
lexical=lexical,
vectors=vectors,
embeddings=FailingEmbeddings(),
)
lexical.configure_vector_generation(
extension_loader=vectors.load_extension,
builder=failing_hybrid.build_vector_generation,
is_current=failing_hybrid.vector_generation_is_current,
)
degraded = CanonicalSearchService(
index=failing_hybrid,
project_ids_provider=lambda: ("dome-performance", "interactive-video"),
canonical_head=repository.head,
).search(request.model_copy(update={"limit": 5}), principal=principal)
assert degraded.retrieval_mode == "keyword"
assert any(item.code == "HYBRID_RETRIEVAL_DEGRADED" for item in degraded.warnings)
assert degraded.next_cursor is None
def test_fts_and_vector_publication_roll_back_as_one_transaction(tmp_path: Path) -> None:
repository = _repository(tmp_path)
search_path = tmp_path / "search.sqlite3"
lexical = SQLiteSearchIndex(search_path, repository)
vectors = SQLiteVectorIndex(search_path)
hybrid = HybridSearchIndex(
lexical=lexical,
vectors=vectors,
embeddings=FixtureEmbeddings(),
)
def fail_after_vector_publish(connection, revision, nodes) -> None:
hybrid.build_vector_generation(connection, revision, nodes)
raise sqlite3.OperationalError("simulated termination before index commit")
lexical.configure_vector_generation(
extension_loader=vectors.load_extension,
builder=fail_after_vector_publish,
is_current=hybrid.vector_generation_is_current,
)
with pytest.raises(SearchIndexError):
lexical.search(
query="alpha",
project_ids=("dome-performance",),
include_shared=False,
limit=5,
)
assert lexical.diagnostic_status()[0] is None
assert vectors.active_generation() is None
lexical.configure_vector_generation(
extension_loader=vectors.load_extension,
builder=hybrid.build_vector_generation,
is_current=hybrid.vector_generation_is_current,
)
recovered = hybrid.search(
query="alpha",
project_ids=("dome-performance",),
include_shared=False,
limit=5,
)
active = vectors.active_generation()
assert recovered.retrieval_mode == "hybrid"
assert active is not None and active.revision == recovered.index_revision
+165
View File
@@ -0,0 +1,165 @@
import hashlib
import json
import subprocess
import sys
from pathlib import Path
import portalocker
import pytest
import yaml
from gewu.adapters.git_repository import GitCanonicalRepository
from gewu.application.registry import LocalContextProvider, LocalProjectRegistry
from gewu.domain.contracts import AutoAcceptPolicy, ProjectRecord, ProjectStatus
from gewu.domain.validation import extraction_digest
from tests.domain.proposal_fixtures import EXTRACTION, NODE, POLICIES, PROJECTS
def test_project_registry_create_and_archive_are_atomic_and_idempotent(tmp_path: Path) -> None:
registry_file = tmp_path / "control" / "registry.json"
registry = LocalProjectRegistry(
registry_file,
repository=GitCanonicalRepository(tmp_path / "knowledge.git"),
index_directory=tmp_path / "publishing",
authority_lock_path=tmp_path / "workflow" / "locks" / "canonical-writer.lock",
)
project = ProjectRecord(
project_id="new-research",
title="New research",
status=ProjectStatus.ACTIVE,
owners=("owner-one",),
north_star="Investigate a new research area.",
key_questions=("What is known?",),
autonomy_policy_id="new-policy",
default_includes=("shared",),
)
policy = AutoAcceptPolicy(
policy_id="new-policy",
policy_version=1,
project_id="new-research",
require_supported_assessment=True,
)
assert registry.create(project, policy) == project
assert registry.repository is not None
revision_after_create = registry.repository.head()
assert (
registry.repository.read_file(revision_after_create, "projects/new-research/project.md")
is not None
)
assert registry.repository.list_files(revision_after_create, "policies")
canonical_context = LocalContextProvider(registry.repository, registry_file)()
assert canonical_context.projects["new-research"] == project
assert canonical_context.policies["new-policy"] == policy
assert registry.records() == (project,)
assert "new-research" in registry()
lock_path = tmp_path / "workflow" / "locks" / "canonical-writer.lock"
with portalocker.Lock(lock_path, mode="a", timeout=0):
with pytest.raises(portalocker.exceptions.LockException):
registry.archive("new-research")
assert registry.archive("new-research").status == ProjectStatus.ARCHIVED
assert registry.repository.head() != revision_after_create
assert registry.archive("new-research").status == ProjectStatus.ARCHIVED
assert registry.records()[0].status == ProjectStatus.ARCHIVED
assert (
LocalContextProvider(registry.repository, registry_file)().projects["new-research"].status
== ProjectStatus.ARCHIVED
)
def test_archive_migrates_legacy_project_to_canonical_registry(tmp_path: Path) -> None:
registry_file = tmp_path / "control" / "registry.json"
registry_file.parent.mkdir(parents=True)
registry_file.write_text(
json.dumps(
{
"schema_version": 1,
"projects": [project.model_dump(mode="json") for project in PROJECTS.values()],
"policies": [policy.model_dump(mode="json") for policy in POLICIES.values()],
}
),
encoding="utf-8",
)
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
registry = LocalProjectRegistry(
registry_file,
repository=repository,
index_directory=tmp_path / "publishing",
authority_lock_path=tmp_path / "workflow" / "locks" / "canonical-writer.lock",
)
archived = registry.archive("dome-performance")
context = LocalContextProvider(repository, registry_file)()
assert archived.status == ProjectStatus.ARCHIVED
assert context.projects["dome-performance"].status == ProjectStatus.ARCHIVED
assert context.projects["interactive-video"] == PROJECTS["interactive-video"]
assert context.policies == POLICIES
def test_canonical_registry_retains_prior_extractions_after_parser_replacement(
tmp_path: Path,
) -> None:
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
prior = EXTRACTION
replacement_candidate = prior.model_copy(
update={"extraction_id": "0" * 64, "parser_version": "replacement-parser-1"}
)
replacement = replacement_candidate.model_copy(
update={"extraction_id": extraction_digest(replacement_candidate)}
)
node_path = (
f"projects/{hashlib.sha256(NODE.project_id.encode()).hexdigest()}/nodes/"
f"{hashlib.sha256(NODE.node_id.encode()).hexdigest()}.yaml"
)
first_revision = repository.publish_files(
expected_head=repository.head(),
files={
f"extractions/{prior.extraction_id}.json": prior.model_dump_json().encode(),
node_path: yaml.safe_dump(NODE.model_dump(mode="json")).encode(),
},
message="Publish a cited node using the original parser extraction",
index_directory=tmp_path / "publishing",
)
replacement_revision = repository.publish_files(
expected_head=repository.head(),
files={
f"extractions/{replacement.extraction_id}.json": replacement.model_dump_json().encode()
},
message="Add replacement parser extraction without deleting history",
index_directory=tmp_path / "publishing",
)
context = LocalContextProvider(repository, tmp_path / "control/registry.json")()
assert context.extractions[prior.extraction_id] == prior
assert context.extractions[replacement.extraction_id] == replacement
assert context.existing_nodes[NODE.node_id] == NODE
assert (
context.existing_nodes[NODE.node_id].claims[0].evidence[0].extraction_id
== prior.extraction_id
)
assert (
repository.read_file(first_revision, f"extractions/{prior.extraction_id}.json") is not None
)
assert (
repository.read_file(replacement_revision, f"extractions/{prior.extraction_id}.json")
is not None
)
def test_disposable_registry_seed_loads_with_versioned_contracts(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("GEWU_DATA_ROOT", str(tmp_path))
result = subprocess.run(
[sys.executable, "scripts/seed_test_registry.py"],
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
context = LocalContextProvider(repository, tmp_path / "control/registry.json")()
assert set(context.projects) == {"dome-performance", "interactive-video"}
assert set(context.policies) == {"dome-pilot-policy", "video-pilot-policy", "shared"}
+106
View File
@@ -0,0 +1,106 @@
from pathlib import Path
import pytest
from pydantic import ValidationError
from gewu.application.acceptance import ActorContext
from gewu.application.runs import LocalRunService, RunError
from gewu.domain.contracts import (
RunCheckpointRequest,
RunCreateRequest,
RunModulePin,
RunState,
Scope,
)
def _create(run_id: str = "run-pilot") -> RunCreateRequest:
return RunCreateRequest(
run_id=run_id,
objective="Compare audience response",
scope=Scope(projects=("dome-performance",), include_shared=False),
lens="Audience behavior",
pack_id="gewu",
pack_version="0.3.0",
pack_snapshot_digest="a" * 64,
module_pins=(
RunModulePin(
module_id="profile",
implementation_version="1.0.0",
sha256="b" * 64,
),
),
)
def _checkpoint(run_id: str = "run-pilot", **overrides: object) -> RunCheckpointRequest:
values: dict[str, object] = {
"run_id": run_id,
"expected_revision": 0,
"stage": "source-review",
"state": RunState.ACTIVE,
"budgets": {"source_count": 3},
}
values.update(overrides)
return RunCheckpointRequest.model_validate(values)
def test_run_create_and_checkpoint_are_idempotent_and_revision_checked(tmp_path: Path) -> None:
current = True
actor = ActorContext("agent-pilot", "pilot-key")
service = LocalRunService(
workflow_root=tmp_path / "workflow",
actor_is_current=lambda _: current,
)
created = service.create(_create(), actor=actor)
assert created.revision == 0
assert service.create(_create(), actor=actor) == created
with pytest.raises(RunError, match="different input"):
service.create(_create().model_copy(update={"objective": "Different"}), actor=actor)
checkpoint = _checkpoint()
updated = service.checkpoint(checkpoint, actor=actor)
assert updated.revision == 1
assert service.checkpoint(checkpoint, actor=actor) == updated
with pytest.raises(RunError, match="changed content"):
service.checkpoint(_checkpoint(stage="different-stage"), actor=actor)
service.checkpoint(_checkpoint(expected_revision=1, stage="next-stage"), actor=actor)
with pytest.raises(RunError, match="stale"):
service.checkpoint(_checkpoint(expected_revision=0, stage="different-stage"), actor=actor)
assert service.status("run-pilot", actor=actor).revision == 2
def test_run_owner_revocation_and_terminal_state_are_enforced(tmp_path: Path) -> None:
active = True
owner = ActorContext("agent-pilot", "pilot-key")
other = ActorContext("agent-other", "other-key")
service = LocalRunService(
workflow_root=tmp_path / "workflow",
actor_is_current=lambda actor: active and actor.credential_id in {"pilot-key", "other-key"},
)
service.create(_create(), actor=owner)
with pytest.raises(RunError, match="unavailable"):
service.status("run-pilot", actor=other)
completed = service.checkpoint(_checkpoint(state=RunState.COMPLETED), actor=owner)
assert completed.state is RunState.COMPLETED
with pytest.raises(RunError, match="cannot be updated"):
service.checkpoint(_checkpoint(expected_revision=1), actor=owner)
active = False
with pytest.raises(RunError, match="no longer active"):
service.checkpoint(_checkpoint(expected_revision=1), actor=owner)
with pytest.raises(RunError, match="no longer active"):
service.status("run-pilot", actor=owner)
def test_run_contract_rejects_global_scope_and_negative_budgets() -> None:
with pytest.raises(ValidationError):
RunCreateRequest.model_validate(
_create().model_dump() | {"scope": Scope(projects=("all",), all_projects=True)}
)
with pytest.raises(ValidationError):
RunCheckpointRequest.model_validate(
{"run_id": "run-pilot", "expected_revision": 0, "stage": "x", "budgets": {"tokens": -1}}
)
+84
View File
@@ -0,0 +1,84 @@
import json
from pathlib import Path
from typing import cast
import pytest
from gewu.application.identity import Principal
from gewu.application.registry import LocalProjectRegistry
from gewu.application.search import CanonicalSearchService, SearchError, SearchIndex
from gewu.domain.contracts import ProjectRecord, ProjectStatus, Scope
def _service() -> CanonicalSearchService:
return CanonicalSearchService(
index=cast(SearchIndex, object()),
project_ids_provider=lambda: ("dome-performance", "interactive-video"),
canonical_head=lambda: "a" * 40,
default_includes_provider=lambda project_id: (
("interactive-video", "shared") if project_id == "dome-performance" else ()
),
)
def _principal(*projects: str) -> Principal:
return Principal("reader", "reader-key", frozenset({"viewer"}), frozenset(projects))
def test_single_project_scope_resolves_defaults_and_shared() -> None:
resolved = _service()._resolve_scope(
Scope(projects=("dome-performance",)),
_principal("dome-performance", "interactive-video", "shared"),
)
assert resolved == ("dome-performance", "interactive-video")
def test_explicit_empty_includes_suppresses_project_defaults() -> None:
resolved = _service()._resolve_scope(
Scope(projects=("dome-performance",), include_projects=(), include_shared=False),
_principal("dome-performance", "interactive-video", "shared"),
)
assert resolved == ("dome-performance",)
def test_default_includes_must_be_authorized() -> None:
with pytest.raises(SearchError, match="cannot read every requested project") as error:
_service()._resolve_scope(
Scope(projects=("dome-performance",)), _principal("dome-performance", "shared")
)
assert error.value.status_code == 403
def test_explicit_include_projects_override_defaults() -> None:
resolved = _service()._resolve_scope(
Scope(projects=("dome-performance",), include_projects=("interactive-video",)),
_principal("dome-performance", "interactive-video", "shared"),
)
assert resolved == ("dome-performance", "interactive-video")
def test_local_project_registry_exposes_validated_default_includes(tmp_path: Path) -> None:
project = ProjectRecord(
project_id="dome-performance",
title="Dome",
status=ProjectStatus.ACTIVE,
north_star="Build useful dome experiences.",
key_questions=("What works?",),
default_includes=("interactive-video", "shared"),
autonomy_policy_id="dome-policy",
)
registry_file = tmp_path / "registry.json"
registry_file.write_text(
json.dumps(
{"schema_version": 1, "projects": [project.model_dump(mode="json")], "policies": []}
),
encoding="utf-8",
)
registry = LocalProjectRegistry(registry_file)
assert registry.default_includes("dome-performance") == ("interactive-video",)
+395
View File
@@ -0,0 +1,395 @@
import hashlib
from pathlib import Path
import pytest
from gewu.application.uploads import CHUNK_BYTES, LocalArtifactStore, UploadError
from tests.domain.proposal_fixtures import EXTRACTION
def _begin(store: LocalArtifactStore, content: bytes, *, now: int = 1_000) -> str:
session = store.begin(
filename="selected.md",
media_type="text/markdown",
byte_length=len(content),
expected_sha256=hashlib.sha256(content).hexdigest(),
now=now,
)
return session.upload_id
def test_matching_chunk_retry_and_restart_are_idempotent(tmp_path: Path) -> None:
content = b"# Evidence\nA source block.\n"
first_store = LocalArtifactStore(tmp_path)
upload_id = _begin(first_store, content)
digest = hashlib.sha256(content).hexdigest()
assert first_store.put_chunk(
upload_id, offset=0, content=content, chunk_sha256=digest, now=1_001
) == (0,)
restarted = LocalArtifactStore(tmp_path)
assert restarted.verified_offsets(upload_id, now=1_002) == (0,)
assert restarted.put_chunk(
upload_id, offset=0, content=content, chunk_sha256=digest, now=1_002
) == (0,)
receipt = restarted.finalize(upload_id, now=1_003)
assert receipt.sha256 == digest
assert receipt.status == "staged"
with restarted.open_artifact(receipt.sha256) as artifact:
assert artifact.read() == content
def test_pasted_capture_receipt_survives_restart_and_conflicts_on_changed_input(
tmp_path: Path,
) -> None:
first = LocalArtifactStore(tmp_path)
result = {"sha256": "a" * 64, "source_candidate": {"revision_id": "paste:r1"}}
with first.capture_receipt_guard():
first.save_capture_receipt(
"capture-1",
owner_id="agent-a",
project_id="project-a",
request_sha256="b" * 64,
result=result,
)
restarted = LocalArtifactStore(tmp_path)
with restarted.capture_receipt_guard():
assert (
restarted.get_capture_receipt(
"capture-1",
owner_id="agent-a",
project_id="project-a",
request_sha256="b" * 64,
)
== result
)
with pytest.raises(UploadError) as error:
restarted.get_capture_receipt(
"capture-1",
owner_id="agent-a",
project_id="project-a",
request_sha256="c" * 64,
)
assert error.value.code == "CAPTURE_CONFLICT"
with pytest.raises(UploadError) as error:
restarted.get_capture_receipt(
"capture-1",
owner_id="agent-a",
project_id="project-b",
request_sha256="b" * 64,
)
assert error.value.code == "CAPTURE_CONFLICT"
assert (
restarted.get_capture_receipt(
"capture-1",
owner_id="agent-b",
project_id="project-a",
request_sha256="b" * 64,
)
is None
)
def test_upload_session_is_private_to_its_principal(tmp_path: Path) -> None:
store = LocalArtifactStore(tmp_path)
content = b"# Scoped upload\n"
upload_id = store.begin(
filename="scoped.md",
media_type="text/markdown",
byte_length=len(content),
expected_sha256=hashlib.sha256(content).hexdigest(),
owner_id="agent-a",
).upload_id
with pytest.raises(UploadError) as error:
store.verified_offsets(upload_id, owner_id="agent-b")
assert error.value.code == "UPLOAD_NOT_FOUND"
with pytest.raises(UploadError) as error:
store.put_chunk(
upload_id,
offset=0,
content=content,
chunk_sha256=hashlib.sha256(content).hexdigest(),
owner_id="agent-b",
)
assert error.value.code == "UPLOAD_NOT_FOUND"
def test_staged_extraction_is_immutable_and_principal_scoped(tmp_path: Path) -> None:
store = LocalArtifactStore(tmp_path)
store.save_extraction(EXTRACTION, owner_id="agent-a")
assert store.get_extraction(EXTRACTION.extraction_id, owner_id="agent-a") == EXTRACTION
assert store.get_extraction(EXTRACTION.extraction_id, owner_id="agent-b") is None
with pytest.raises(UploadError) as error:
store.save_extraction(EXTRACTION, owner_id="agent-b")
assert error.value.code == "EXTRACTION_STORE_CONFLICT"
def test_artifact_access_is_scoped_to_principal_and_project(tmp_path: Path) -> None:
store = LocalArtifactStore(tmp_path)
content = b"private project source"
digest = hashlib.sha256(content).hexdigest()
upload_id = store.begin(
filename="source.md",
media_type="text/markdown",
byte_length=len(content),
expected_sha256=digest,
owner_id="agent-a",
project_id="project-a",
).upload_id
store.put_chunk(
upload_id,
offset=0,
content=content,
chunk_sha256=digest,
owner_id="agent-a",
)
store.finalize(upload_id, owner_id="agent-a")
assert (
store.artifact_media_type(digest, owner_id="agent-a", project_id="project-a")
== "text/markdown"
)
with store.open_artifact(digest, owner_id="agent-a", project_id="project-a") as stream:
assert stream.read() == content
with pytest.raises(UploadError):
store.open_artifact(digest, owner_id="agent-b", project_id="project-a")
with pytest.raises(UploadError):
store.open_artifact(digest, owner_id="agent-a", project_id="project-b")
def test_changed_retry_at_same_offset_is_rejected(tmp_path: Path) -> None:
store = LocalArtifactStore(tmp_path)
upload_id = _begin(store, b"abc")
store.put_chunk(
upload_id,
offset=0,
content=b"abc",
chunk_sha256=hashlib.sha256(b"abc").hexdigest(),
now=1_001,
)
with pytest.raises(UploadError, match="different bytes") as error:
store.put_chunk(
upload_id,
offset=0,
content=b"xyz",
chunk_sha256=hashlib.sha256(b"xyz").hexdigest(),
now=1_002,
)
assert error.value.code == "IDEMPOTENCY_CONFLICT"
def test_chunk_and_complete_artifact_digests_are_verified(tmp_path: Path) -> None:
store = LocalArtifactStore(tmp_path)
content = b"verified bytes"
digest = hashlib.sha256(content).hexdigest()
upload_id = store.begin(
filename="verified.md",
media_type="text/markdown",
byte_length=len(content),
expected_sha256="f" * 64,
).upload_id
with pytest.raises(UploadError) as error:
store.put_chunk(
upload_id,
offset=0,
content=content,
chunk_sha256="0" * 64,
)
assert error.value.code == "CHUNK_DIGEST_MISMATCH"
store.put_chunk(upload_id, offset=0, content=content, chunk_sha256=digest)
with pytest.raises(UploadError) as error:
store.finalize(upload_id)
assert error.value.code == "ARTIFACT_DIGEST_MISMATCH"
with pytest.raises(UploadError) as error:
store.open_artifact("f" * 64)
assert error.value.code == "ARTIFACT_NOT_FOUND"
def test_interrupted_upload_does_not_publish_an_artifact(tmp_path: Path) -> None:
store = LocalArtifactStore(tmp_path)
content = b"a" * (CHUNK_BYTES + 1)
digest = hashlib.sha256(content).hexdigest()
upload_id = store.begin(
filename="partial.md",
media_type="text/markdown",
byte_length=len(content),
expected_sha256=digest,
owner_id="agent-a",
project_id="project-a",
).upload_id
first_chunk = content[:CHUNK_BYTES]
store.put_chunk(
upload_id,
offset=0,
content=first_chunk,
chunk_sha256=hashlib.sha256(first_chunk).hexdigest(),
owner_id="agent-a",
)
with pytest.raises(UploadError) as error:
store.finalize(upload_id, owner_id="agent-a")
assert error.value.code == "UPLOAD_INCOMPLETE"
with pytest.raises(UploadError) as error:
store.open_artifact(digest, owner_id="agent-a", project_id="project-a")
assert error.value.code == "ARTIFACT_NOT_FOUND"
def test_symlinked_upload_paths_are_not_followed(tmp_path: Path) -> None:
store = LocalArtifactStore(tmp_path)
outside = tmp_path / "outside"
outside.mkdir()
sensitive = outside / "chunk"
sensitive.write_bytes(b"data")
forged_id = "A" * 24
try:
(store.upload_root / forged_id).symlink_to(outside, target_is_directory=True)
except OSError as error:
pytest.skip(f"directory symlinks are unavailable: {error}")
with pytest.raises(UploadError) as error:
store.verified_offsets(forged_id)
assert error.value.code == "UPLOAD_NOT_FOUND"
upload_id = _begin(store, b"data")
chunk_link = store.upload_root / upload_id / f"{0:020d}.chunk"
chunk_link.symlink_to(sensitive)
assert store.verified_offsets(upload_id, now=1_001) == ()
with pytest.raises(UploadError) as error:
store.finalize(upload_id, now=1_001)
assert error.value.code == "UPLOAD_INCOMPLETE"
assert sensitive.read_bytes() == b"data"
def test_upload_requires_order_complete_digest_and_utf8(tmp_path: Path) -> None:
store = LocalArtifactStore(tmp_path)
content = b"a" * (CHUNK_BYTES + 1)
upload_id = _begin(store, content)
final_chunk = content[CHUNK_BYTES:]
with pytest.raises(UploadError, match="in order"):
store.put_chunk(
upload_id,
offset=CHUNK_BYTES,
content=final_chunk,
chunk_sha256=hashlib.sha256(final_chunk).hexdigest(),
now=1_001,
)
with pytest.raises(UploadError) as error:
store.finalize(upload_id, now=1_002)
assert error.value.code == "UPLOAD_INCOMPLETE"
invalid_bytes = b"\xff"
invalid_id = _begin(store, invalid_bytes)
store.put_chunk(
invalid_id,
offset=0,
content=invalid_bytes,
chunk_sha256=hashlib.sha256(invalid_bytes).hexdigest(),
now=1_001,
)
with pytest.raises(UploadError, match="UTF-8") as error:
store.finalize(invalid_id, now=1_002)
assert error.value.code == "TEXT_ENCODING_INVALID"
def test_rejects_unsupported_types_expiry_and_malicious_ids(tmp_path: Path) -> None:
store = LocalArtifactStore(tmp_path)
with pytest.raises(UploadError) as error:
store.begin(
filename="payload.exe",
media_type="application/octet-stream",
byte_length=4,
expected_sha256=hashlib.sha256(b"data").hexdigest(),
now=1_000,
)
assert error.value.code == "UNSUPPORTED_MEDIA_TYPE"
upload_id = _begin(store, b"data")
with pytest.raises(UploadError) as error:
store.verified_offsets(upload_id, now=1_601)
assert error.value.code == "UPLOAD_EXPIRED"
with pytest.raises(UploadError) as error:
store.verified_offsets("../../outside", now=1_001)
assert error.value.code == "UPLOAD_NOT_FOUND"
def test_pdf_upload_requires_matching_suffix_and_pdf_signature(tmp_path: Path) -> None:
store = LocalArtifactStore(tmp_path)
payload = b"%PDF-fixture"
digest = hashlib.sha256(payload).hexdigest()
with pytest.raises(UploadError) as error:
store.begin(
filename="source.txt",
media_type="application/pdf",
byte_length=len(payload),
expected_sha256=digest,
)
assert error.value.code == "UNSUPPORTED_MEDIA_TYPE"
upload_id = store.begin(
filename="source.pdf",
media_type="application/pdf",
byte_length=len(payload),
expected_sha256=digest,
).upload_id
store.put_chunk(upload_id, offset=0, content=payload, chunk_sha256=digest)
assert store.finalize(upload_id).media_type == "application/pdf"
invalid = b"not pdf"
invalid_digest = hashlib.sha256(invalid).hexdigest()
invalid_id = store.begin(
filename="invalid.pdf",
media_type="application/pdf",
byte_length=len(invalid),
expected_sha256=invalid_digest,
).upload_id
store.put_chunk(invalid_id, offset=0, content=invalid, chunk_sha256=invalid_digest)
with pytest.raises(UploadError) as error:
store.finalize(invalid_id)
assert error.value.code == "PDF_INVALID"
def test_identical_artifact_bytes_dedupe_without_collapsing_upload_receipts(
tmp_path: Path,
) -> None:
store = LocalArtifactStore(tmp_path)
content = b"Shared source content."
digest = hashlib.sha256(content).hexdigest()
first_id = store.begin(
filename="shared.md",
media_type="text/markdown",
byte_length=len(content),
expected_sha256=digest,
owner_id="agent-shared",
project_id="project-a",
).upload_id
second_session = store.begin(
filename="second-copy.txt",
media_type="text/plain",
byte_length=len(content),
expected_sha256=digest,
owner_id="agent-shared",
project_id="project-b",
now=1_000,
)
for upload_id in (first_id, second_session.upload_id):
store.put_chunk(
upload_id,
offset=0,
content=content,
chunk_sha256=digest,
owner_id="agent-shared",
now=1_001,
)
first = store.finalize(first_id, owner_id="agent-shared", now=1_002)
second = store.finalize(second_session.upload_id, owner_id="agent-shared", now=1_002)
assert first.sha256 == second.sha256
assert first.byte_length == second.byte_length == len(content)
for project_id in ("project-a", "project-b"):
with store.open_artifact(
digest, owner_id="agent-shared", project_id=project_id
) as artifact:
assert artifact.read() == content
with pytest.raises(UploadError) as error:
store.open_artifact(digest, owner_id="agent-shared", project_id="project-c")
assert error.value.code == "ARTIFACT_NOT_FOUND"